<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Arseny Zinchenko</title>
    <description>The latest articles on Forem by Arseny Zinchenko (@setevoy).</description>
    <link>https://forem.com/setevoy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F33441%2F407b2263-6b99-44d0-8630-be9cbd51a255.jpg</url>
      <title>Forem: Arseny Zinchenko</title>
      <link>https://forem.com/setevoy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/setevoy"/>
    <language>en</language>
    <item>
      <title>AWS: Monitoring AWS OpenSearch Service Cluster with CloudWatch</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Wed, 31 Dec 2025 10:00:00 +0000</pubDate>
      <link>https://forem.com/setevoy/aws-monitoring-aws-opensearch-service-cluster-with-cloudwatch-385o</link>
      <guid>https://forem.com/setevoy/aws-monitoring-aws-opensearch-service-cluster-with-cloudwatch-385o</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" width="640" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s continue our journey with AWS OpenSearch Service.&lt;/p&gt;

&lt;p&gt;What we have is a small AWS OpenSearch Service cluster with three data nodes, used as a vector store for AWS Bedrock Knowledge Bases.&lt;/p&gt;

&lt;p&gt;Previous parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;AWS: Introduction to OpenSearch Service as a vector store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/" rel="noopener noreferrer"&gt;AWS: Creating an OpenSearch Service cluster and configuring authentication and authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rtfm.co.ua/terraform-stvorennya-aws-opensearch-service-cluster-ta-yuzeriv/" rel="noopener noreferrer"&gt;Terraform: creating an AWS OpenSearch Service cluster and users&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We already had our first production incident :-)&lt;/p&gt;

&lt;p&gt;We launched a search without filters, and our &lt;code&gt;t3.small.search&lt;/code&gt; died due to CPU overload.&lt;/p&gt;

&lt;p&gt;So let’s take a look at what we have in terms of monitoring all this happiness.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;CloudWatch metrics&lt;/li&gt;
&lt;li&gt;Memory monitoring&lt;/li&gt;
&lt;li&gt;kNN Memory usage&lt;/li&gt;
&lt;li&gt;JVM Memory usage&lt;/li&gt;
&lt;li&gt;Collecting metrics to VictoriaMetrics&lt;/li&gt;
&lt;li&gt;Creating a Grafana dashboard&lt;/li&gt;
&lt;li&gt;VictoriaMetrics/Prometheus sum(), avg() та max()&lt;/li&gt;
&lt;li&gt;Cluster status&lt;/li&gt;
&lt;li&gt;Nodes status&lt;/li&gt;
&lt;li&gt;CPUUtilization: Stats&lt;/li&gt;
&lt;li&gt;CPUUtilization: Graph&lt;/li&gt;
&lt;li&gt;JVMMemoryPressure: Graph&lt;/li&gt;
&lt;li&gt;JVMGCYoungCollectionCount and JVMGCOldCollectionCount&lt;/li&gt;
&lt;li&gt;KNNHitCount vs KNNMissCount&lt;/li&gt;
&lt;li&gt;Final result&lt;/li&gt;
&lt;li&gt;t3.small.search vs t3.medium.search on graphs&lt;/li&gt;
&lt;li&gt;Creating Alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now let’s do something basic, just with CloudWatch metrics, but there are several solutions for monitoring OpenSearch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CloudWatch metrics from OpenSearchService itself — data on CPU, memory, and JVM, which we can collect in VictoriaMetrics and generate alerts or use in the Grafana dashboard, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-cloudwatchmetrics.html" rel="noopener noreferrer"&gt;Monitoring OpenSearch cluster metrics with Amazon CloudWatch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CloudWatch Events generated by OpenSearch Service — see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/monitoring-events.html#monitoring-events-throughput-throttle" rel="noopener noreferrer"&gt;Monitoring OpenSearch Service events with Amazon EventBridge&lt;/a&gt; — can be sent via SNS to Opsgenie, and from there to Slack.&lt;/li&gt;
&lt;li&gt;Logs in CloudWatch Logs — we can collect them in VictoriaLogs and generate some metrics and alerts, but I didn’t see anything interesting in the logs during our production incident, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createdomain-configure-slow-logs.html" rel="noopener noreferrer"&gt;Monitoring OpenSearch logs with Amazon CloudWatch Logs&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.opensearch.org/latest/observing-your-data/alerting/monitors/" rel="noopener noreferrer"&gt;Monitors&lt;/a&gt; of OpenSearch itself — capable of anomaly detection and custom alerting, there is even a Terraform resource &lt;a href="https://registry.terraform.io/providers/opensearch-project/opensearch/latest/docs/resources/monitor" rel="noopener noreferrer"&gt;&lt;code&gt;opensearch_monitor&lt;/code&gt;&lt;/a&gt;, see also &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/alerting.html" rel="noopener noreferrer"&gt;Configuring alerts in Amazon OpenSearch Service&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;There is also the Prometheus Exporter Plugin, which opens an endpoint for collecting metrics from Prometheus/VictoriaMetrics (but it cannot be added to AWS OpenSearch Managed, although support promises that there is a feature request — maybe it will be added someday).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  CloudWatch metrics
&lt;/h3&gt;

&lt;p&gt;There are quite a few metrics, but the ones that may be of interest to us are those that take into account the fact that we do not have dedicated master and coordinator nodes, and we do not use ultra-warm and cold instances.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cluster metrics&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ClusterStatus&lt;/code&gt;: &lt;code&gt;green&lt;/code&gt;/&lt;code&gt;yellow&lt;/code&gt;/&lt;code&gt;red&lt;/code&gt; - the main indicator of cluster status, control of data shard activity&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Shards&lt;/code&gt;: &lt;code&gt;active&lt;/code&gt;/&lt;code&gt;unassigned&lt;/code&gt;/&lt;code&gt;delayedUnassigned&lt;/code&gt;/&lt;code&gt;activePrimary&lt;/code&gt;/&lt;code&gt;initializing&lt;/code&gt;/&lt;code&gt;relocating&lt;/code&gt; - more detailed information on the status of shards, but here is just the total number, without details on specific indexes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Nodes&lt;/code&gt;: the number of nodes in the cluster - knowing how many live nodes there should be - we can alert when a node goes down&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SearchableDocuments&lt;/code&gt;: not that it's particularly interesting to us, but it might be useful later on to see what's going on in the indexes in general.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CPUUtilization&lt;/code&gt;: the percentage of CPU usage across all nodes, and this is a must-have&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FreeStorageSpace&lt;/code&gt;: also useful to monitor&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ClusterIndexWritesBlocked&lt;/code&gt;: Is everything OK with index writes?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JVMMemoryPressure&lt;/code&gt; and &lt;code&gt;OldGenJVMMemoryPressure&lt;/code&gt;: percentage of JVM heap memory usage - we'll dig into JVM monitoring separately later, because it's a whole other headache.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AutomatedSnapshotFailure&lt;/code&gt;: probably good to know if the backup fails&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CPUCreditBalance&lt;/code&gt;: useful for us because we are on t3 instances (but we don't have it in CloudWatch)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2xx&lt;/code&gt;, &lt;code&gt;3xx&lt;/code&gt;, &lt;code&gt;4xx&lt;/code&gt;, &lt;code&gt;5xx&lt;/code&gt;: data on HTTP requests and errors; I only collect &lt;code&gt;5xx&lt;/code&gt; for alerts here&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ThroughputThrottle&lt;/code&gt; and &lt;code&gt;IopsThrottle&lt;/code&gt;: we encountered disk access issues in RDS, so it is worth monitoring here as well, see &lt;a href="https://rtfm.co.ua/en/postgresql-aws-rds-performance-and-monitoring-2/" rel="noopener noreferrer"&gt;PostgreSQL: AWS RDS Performance and monitoring&lt;/a&gt;; here you may need to look at the metrics from &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-cloudwatchmetrics.html#managedomains-cloudwatchmetrics-master-ebs-metrics" rel="noopener noreferrer"&gt;EBS volume metrics&lt;/a&gt;, but for start, you can simply add alerts to Throttle in general&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HighSwapUsage&lt;/code&gt;: similar to the previous metrics - we once had a problem with RDS, so it's better to monitor this as well.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;EBS volume metrics &lt;/strong&gt; — these are basically standard EBS metrics, as for EC2 or RDS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ReadLatency&lt;/code&gt; and &lt;code&gt;WriteLatency&lt;/code&gt;: read/write delays

&lt;ul&gt;
&lt;li&gt;sometimes there are spikes, so you can add&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ReadThroughput&lt;/code&gt; and &lt;code&gt;WriteThroughput&lt;/code&gt;: total disk load, let's say this way&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;DiskQueueDepth&lt;/code&gt;: I/O operations queue

&lt;ul&gt;
&lt;li&gt;is empty in CloudWatch (for now?), so we’ll skip it&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ReadIOPS&lt;/code&gt; and &lt;code&gt;WriteIOPS&lt;/code&gt;: number of read/write operations per second&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Instance metrics&lt;/strong&gt;  — here are the metrics for each OpenSearch instance (not the server, EC2, but OpenSearch itself) on each node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FetchLatency&lt;/code&gt; and &lt;code&gt;FetchRate&lt;/code&gt;: how quickly we get data from shards (but I couldn't find it in CloudWatch either)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ThreadCount&lt;/code&gt;: the number of threads in the operating system that were created by the JVM (Garbage Collector threads, search threads, write/index threads, etc.)&lt;/li&gt;
&lt;li&gt;The value is stable in CloudWatch, but for now, we can add it to Grafana for the overall picture and see if there is anything interesting there&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ShardReactivateCount&lt;/code&gt;: how often shards are transferred from cold/inactive states to active ones, which requires operating system resources, CPU, and memory; Well... maybe we should check if it has any significance for us at all.

&lt;ul&gt;
&lt;li&gt;But there is nothing in CloudWatch either — “&lt;em&gt;did not match any metrics&lt;/em&gt;”&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ConcurrentSearchRate&lt;/code&gt; and &lt;code&gt;ConcurrentSearchLatency&lt;/code&gt;: the number and speed of simultaneous search requests - this can be interesting if there are many parallel requests hanging for a long time&lt;/li&gt;

&lt;li&gt;but for us (yet?), these values are constantly at zero, so we skip them&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;SearchRate&lt;/code&gt;: number of search queries per minute, useful for the overall picture&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;SearchLatency&lt;/code&gt;: search query execution speed, probably very useful, you can even set up an alert&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;IndexingRate&lt;/code&gt; and &lt;code&gt;IndexingLatency&lt;/code&gt;: similar, but for indexing new documents&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;SysMemoryUtilization&lt;/code&gt;: percentage of memory usage on the data node, but this does not give a complete picture; you need to look at the JVM memory.&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;JVMGCYoungCollectionCount&lt;/code&gt; and &lt;code&gt;JVMGCOldCollectionCount&lt;/code&gt;: the number of Garbage Collector runs, useful in conjunction with JVM memory data, which we will discuss in more detail later.&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;SearchTaskCancelled&lt;/code&gt; and &lt;code&gt;SearchShardTaskCancelled&lt;/code&gt;: bad news :-) if tasks are canceled, something is clearly wrong (either the user interrupted the request, or there was an HTTP connection reset, or timeouts, or cluster load)

&lt;ul&gt;
&lt;li&gt;but we always have zeroes, even when the cluster went down, so I don’t see the point in collecting these metrics yet&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexQueue&lt;/code&gt; and &lt;code&gt;ThreadpoolSearchQueue&lt;/code&gt;: the number of tasks for indexing and searching in the queue; when there are too many of them, we get &lt;code&gt;ThreadpoolIndexRejected&lt;/code&gt; and &lt;code&gt;ThreadpoolSearchRejected&lt;/code&gt;
&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexQueue&lt;/code&gt; is not available in CloudWatch at all, and &lt;code&gt;ThreadpoolSearchQueue&lt;/code&gt; is there, but it's also constantly at zero, so we're skipping it for now&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexRejected&lt;/code&gt; and &lt;code&gt;ThreadpoolSearchRejected&lt;/code&gt;: actually, above&lt;/li&gt;

&lt;li&gt;in CloudWatch, the picture is similar — ThreadpoolIndexRejected is not present at all, ThreadpoolSearchRejected is zero&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexThreads&lt;/code&gt; and &lt;code&gt;ThreadpoolSearchThreads&lt;/code&gt;: the maximum number of operating system threads for indexing and searching; if all are busy, requests will go to &lt;code&gt;ThreadpoolIndexQueue&lt;/code&gt;/&lt;code&gt;ThreadpoolSearchQueue&lt;/code&gt;

&lt;ul&gt;
&lt;li&gt;OpenSearch has several types of pools for threads — search, index, write, etc., and each pool has a threads indicator (how many are allocated), see &lt;a href="https://opster.com/guides/opensearch/opensearch-basics/threadpool/" rel="noopener noreferrer"&gt;OpenSearch Threadpool&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The Node Stats API (&lt;code&gt;GET _nodes/stats/thread_pool&lt;/code&gt;) has an active threads metric, but I don't see it in CloudWatch.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ThreadpoolIndexThreads&lt;/code&gt; is not available in CloudWatch at all, and &lt;code&gt;ThreadpoolSearchThreads&lt;/code&gt; is static, so I think we can skip monitoring them for now.&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;PrimaryWriteRejected&lt;/code&gt;: rejected write operations in primary shards due to issues in the thread pool write or index, or load on the data node

&lt;ul&gt;
&lt;li&gt;CloudWatch is empty for now, but we will add collection and alerts&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;ReplicaWriteRejected&lt;/code&gt;: rejected write operations in replica shards - added to the primary document, but cannot be written to the replica

&lt;ul&gt;
&lt;li&gt;CloudWatch is empty for now, but we will add collection and alerts&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;k-NN metrics&lt;/strong&gt;  — useful for us because we have a vector store with k-NN:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KNNCacheCapacityReached&lt;/code&gt;: when the cache is full (see below)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNEvictionCount&lt;/code&gt;: how often data is removed from the cache - a sign that there is not enough memory&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;: off-heap memory usage for the vector graph itself&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphQueryErrors&lt;/code&gt;: number of errors when searching in vectors

&lt;ul&gt;
&lt;li&gt;in CloudWatch are empty for now, but we will add collection and alert&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;KNNGraphQueryRequests&lt;/code&gt;: total number of queries to k-NN graphs&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;KNNHitCount&lt;/code&gt; and &lt;code&gt;KNNMissCount&lt;/code&gt;: how many results were returned from the cache, and how many had to be read from the disk&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;KNNTotalLoadTime&lt;/code&gt;: speed of loading from disk to cache (large graphs or loaded EBS - time will increase)&lt;/li&gt;

&lt;/ul&gt;

&lt;h3&gt;
  
  
  Memory monitoring
&lt;/h3&gt;

&lt;p&gt;Let’s think about how we can monitor the main indicators, starting with memory, because, well, this is Java.&lt;/p&gt;

&lt;p&gt;What do we have about memory metrics?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SysMemoryUtilization&lt;/code&gt;: percentage of memory usage on the server (data node) in general&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;JVMMemoryPressure&lt;/code&gt;: total percentage of JVM Heap usage; JVM Heap is allocated by default to 50% of the server's memory, but no more than 32 gigabytes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OldGenJVMMemoryPressure&lt;/code&gt;: see below&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;: this was discussed in the first post - &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;AWS: introduction to OpenSearch Service as a vector store&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CloudWatch also has a metric called &lt;code&gt;KNNGraphMemoryUsagePercentage&lt;/code&gt;, but it is not included in the documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  kNN Memory usage
&lt;/h3&gt;

&lt;p&gt;First, a brief overview of k-NN memory.&lt;/p&gt;

&lt;p&gt;So, on EC2, we allocate memory for the JVM Heap (50% of what is available on the server) and separately for the off-heap for the OpenSearch vector store, where it keeps graphs and cache. For vector store, see &lt;a href="https://docs.opensearch.org/1.0/search-plugins/knn/approximate-knn/" rel="noopener noreferrer"&gt;Approximate k-NN search&lt;/a&gt;, plus the operating system itself and its file cache.&lt;/p&gt;

&lt;p&gt;We don’t have a metric like “&lt;em&gt;KNNGraphMemoryAvailable&lt;/em&gt;,” but with &lt;code&gt;KNNGraphMemoryUsagePercentage&lt;/code&gt; and &lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;, we can calculate it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;: we currently have 662 megabytes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsagePercentage&lt;/code&gt;: 60%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means that 1 gigabyte is allocated outside the JVM Heap memory for k-NN graphs (this is on &lt;code&gt;t3.medium.search&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;From the documentation &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/knn.html?utm_source=chatgpt.com" rel="noopener noreferrer"&gt;k-Nearest Neighbor (k-NN) search in Amazon OpenSearch Service&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;OpenSearch Service uses half of an instance’s RAM for the Java heap (up to a heap size of 32 GiB). By default, k-NN uses up to 50% of the remaining half&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Knowing that we currently have &lt;a href="https://instances.vantage.sh/aws/opensearch/t3.medium.search?currency=USD" rel="noopener noreferrer"&gt;&lt;code&gt;t3.medium.search&lt;/code&gt;&lt;/a&gt;, which provides 4 gigabytes of memory - 2 GB goes to the JVM Heap, and 1 gigabyte goes to the k-NN graph.&lt;/p&gt;

&lt;p&gt;The main part of KNNGraphMemory is used by the k-NN cache, i.e., the part of the system's RAM where OpenSearch keeps HNSW graphs from vector indexes so that they do not have to be read from disk each time (see &lt;a href="https://docs.opensearch.org/latest/vector-search/api/knn/#k-nn-clear-cache" rel="noopener noreferrer"&gt;k-NN clear cache&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Therefore, it is useful to have graphs for EBS IOPS and k-NN cache usage.&lt;/p&gt;

&lt;h3&gt;
  
  
  JVM Memory usage
&lt;/h3&gt;

&lt;p&gt;Okay, let’s review what’s going on in Java in general. See &lt;a href="https://sematext.com/glossary/jvm-heap/" rel="noopener noreferrer"&gt;What Is Java Heap Memory?&lt;/a&gt;, &lt;a href="https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/" rel="noopener noreferrer"&gt;OpenSearch Heap Size Usage and JVM Garbage Collection&lt;/a&gt;, and &lt;a href="https://aws.amazon.com/blogs/big-data/understanding-the-jvmmemorypressure-metric-changes-in-amazon-opensearch-service/?utm_source=chatgpt.com" rel="noopener noreferrer"&gt;Understanding the JVMMemoryPressure metric changes in Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;To put it simply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stack Memory:&lt;/strong&gt; in addition to the JVM Heap, we have a Stack, which is allocated to each thread, where it keeps its variables, references, and startup parameters

&lt;ul&gt;
&lt;li&gt;set via &lt;code&gt;-Xss&lt;/code&gt;, default value from 256 kilobytes to 1 megabyte, see &lt;a href="https://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/geninfo/diagnos/thread_basics.html" rel="noopener noreferrer"&gt;Understanding Threads and Locks&lt;/a&gt; (couldn't find how to view in OpenSearch Service)&lt;/li&gt;
&lt;li&gt;if we have many threads, there will be a lot of memory for their stacks&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;cleared when the thread dies&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Heap Space&lt;/strong&gt; :&lt;/li&gt;

&lt;li&gt;used to allocate memory that is available to all threads&lt;/li&gt;

&lt;li&gt;managed by Garbage Collectors (GC)&lt;/li&gt;

&lt;li&gt;in the context of OpenSearch, we will have search and indexation caches here&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;In Heap memory, we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Young Generation&lt;/strong&gt; : fresh data, all new objects&lt;/li&gt;
&lt;li&gt;data from here is either deleted completely or moved to Old Generation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old Generation&lt;/strong&gt; : the OpenSearch process code itself, caches, Lucene index structures, large arrays&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If &lt;code&gt;OldGenJVMMemoryPressure&lt;/code&gt; is full, it means that the Garbage Collector cannot clean it up because there are references to the data, and then we have a problem - because there is no space in the Heap for new data, and the JVM may crash with an OutOfMemoryError.&lt;/p&gt;

&lt;p&gt;In general, “heap pressure” is when there is little free memory in Young Gen and Old Gen, and there is nowhere to place new data to respond to clients.&lt;/p&gt;

&lt;p&gt;This leads to frequent Garbage Collector runs, which take up time and system resources — instead of processing requests from clients.&lt;/p&gt;

&lt;p&gt;As a result, latency increases, indexing of new documents slows down, or we get &lt;code&gt;ClusterIndexWritesBlocked&lt;/code&gt; - to avoid Java &lt;strong&gt;OutOfMemoryError&lt;/strong&gt; , because when indexing, OpenSearch first writes data to the Heap and then "dumps" it to disk.&lt;/p&gt;

&lt;p&gt;See &lt;a href="https://sematext.com/blog/jvm-metrics/" rel="noopener noreferrer"&gt;Key JVM Metrics to Monitor for Peak Java Application Performance&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So, to get a picture of memory usage, we monitor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SysMemoryUtilization&lt;/code&gt; - for an overall picture of the EC2 status

&lt;ul&gt;
&lt;li&gt;in our case, it will be consistently around 90%, but that’s OK&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;JVMMemoryPressure&lt;/code&gt; - for an overall picture of the JVM

&lt;ul&gt;
&lt;li&gt;should be cleaned regularly with Garbage Collector (GC)&lt;/li&gt;
&lt;li&gt;if it is constantly above 80–90%, there are problems with running GC&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;OldGenJVMMemoryPressure&lt;/code&gt; - for Old Generation Heap data

&lt;ul&gt;
&lt;li&gt;should be at 30–40%; if it is higher and is not being cleared, then there are problems either with the code or with GC&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;
&lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt; - in our case, this is necessary for the overall picture&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;It is worth adding alerts for &lt;code&gt;HighSwapUsage&lt;/code&gt; - we already had active swapping when we launched on &lt;code&gt;t3.small.search&lt;/code&gt;, and this is an indication that there is not enough memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collecting metrics to VictoriaMetrics
&lt;/h3&gt;

&lt;p&gt;So, how do you choose metrics?&lt;/p&gt;

&lt;p&gt;First, we look for them in CloudWatch Metrics and see if the metric exists at all and if it returns any interesting data.&lt;/p&gt;

&lt;p&gt;For example, &lt;code&gt;SysMemoryUtilization&lt;/code&gt; provides information.&lt;/p&gt;

&lt;p&gt;Here we had a spike on &lt;code&gt;t3.small.search&lt;/code&gt;, after which the cluster crashed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnmet4thyahram7qlg250.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnmet4thyahram7qlg250.png" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But the &lt;code&gt;HighSwapUsage&lt;/code&gt; metric also needs to be moved to &lt;code&gt;t3.medium.search&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjrux00i2ubtizcfc0o0k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjrux00i2ubtizcfc0o0k.png" width="608" height="661"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ClusterStatus&lt;/code&gt; is present:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7c1od192dkmm5ojq1igv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7c1od192dkmm5ojq1igv.png" width="608" height="661"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Shards&lt;/code&gt; exist, but they are indexed by all criteria, and there is no way to filter by individual criteria:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fifo88tza9ji3kprmvu7v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fifo88tza9ji3kprmvu7v.png" width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is also important to note that collecting metrics from CloudWatch also costs money for API requests, so it is not advisable to collect everything indiscriminately.&lt;/p&gt;

&lt;p&gt;In general, we use YACE (Yet Another CloudWatch Exporter) to collect metrics from CloudWatch, but it does not support OpenSearch Managed cluster, see &lt;a href="https://github.com/prometheus-community/yet-another-cloudwatch-exporter?tab=readme-ov-file#features" rel="noopener noreferrer"&gt;Features&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Therefore, we will use a standard exporter — CloudWatch Exporter.&lt;/p&gt;

&lt;p&gt;We deploy it from the Helm monitoring chart (see &lt;a href="https://rtfm.co.ua/en/victoriametrics-deploying-a-kubernetes-monitoring-stack/" rel="noopener noreferrer"&gt;VictoriaMetrics: creating a Kubernetes monitoring stack with your own Helm chart&lt;/a&gt;), add a new config to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...

prometheus-cloudwatch-exporter:
  enabled: true
  serviceAccount:
    name: "cloudwatch-sa"
    annotations:
      eks.amazonaws.com/sts-regional-endpoints: "true"
  serviceMonitor:
    enabled: true
  config: |-
    region: us-east-1
    metrics:

    - aws_namespace: AWS/ES
      aws_metric_name: KNNGraphMemoryUsage
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: SysMemoryUtilization
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: JVMMemoryPressure
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]

    - aws_namespace: AWS/ES
      aws_metric_name: OldGenJVMMemoryPressure
      aws_dimensions: [ClientId, DomainName, NodeId]
      aws_statistics: [Average]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Please note that different metrics may have different &lt;code&gt;Dimensions&lt;/code&gt; - check them in CloudWatch:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ntrfwvf28gni62jeqfb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ntrfwvf28gni62jeqfb.png" width="573" height="128"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Deploy, check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd1rqd5g5uvguewwi3mn3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd1rqd5g5uvguewwi3mn3.png" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And even the numbers turned out to be as we &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/#Number_of_vectors" rel="noopener noreferrer"&gt;calculated in the first post&lt;/a&gt; — we have ~130,000 documents in the production index, according to the formula &lt;code&gt;num_vectors * 1.1 * (4*1024 + 8*16)&lt;/code&gt;, which equals 604032000 bytes, or 604.032 megabytes.&lt;/p&gt;

&lt;p&gt;And on the graph we have 662,261 kilobytes — that’s 662 megabytes, but across all indexes combined.&lt;/p&gt;

&lt;p&gt;Now we have metrics in VictoriaMetrics : &lt;code&gt;aws_es_knngraph_memory_usage_average&lt;/code&gt;, &lt;code&gt;aws_es_sys_memory_utilization_average&lt;/code&gt;, &lt;code&gt;aws_es_jvmmemory_pressure_average&lt;/code&gt;, &lt;code&gt;aws_es_old_gen_jvmmemory_pressure_average&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Add the rest in the same way.&lt;/p&gt;

&lt;p&gt;To find out what metrics are called in VictoriaMetrics/Prometheus, open the port to CloudWatch Exporter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk port-forward svc/atlas-victoriametrics-prometheus-cloudwatch-exporter 9106
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And search for metrics with &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:9106/metrics | grep aws_es
# HELP aws_es_cluster_status_green_maximum CloudWatch metric AWS/ES ClusterStatus.green Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_green_maximum gauge
aws_es_cluster_status_green_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 1.0 1758014700000
# HELP aws_es_cluster_status_yellow_maximum CloudWatch metric AWS/ES ClusterStatus.yellow Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_yellow_maximum gauge
aws_es_cluster_status_yellow_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 0.0 1758014700000
# HELP aws_es_cluster_status_red_maximum CloudWatch metric AWS/ES ClusterStatus.red Dimensions: [ClientId, DomainName] Statistic: Maximum Unit: Count
# TYPE aws_es_cluster_status_red_maximum gauge
aws_es_cluster_status_red_maximum{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",client_id="492***148",} 0.0 1758014700000
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Creating a Grafana dashboard
&lt;/h3&gt;

&lt;p&gt;OK, we have metrics from CloudWatch — that’s enough for now.&lt;/p&gt;

&lt;p&gt;Let’s think about what we want to see in Grafana.&lt;/p&gt;

&lt;p&gt;The general idea is to create a kind of dashboard overview, where all the key data for the cluster will be displayed on a single board.&lt;/p&gt;

&lt;p&gt;What metrics are currently available, and how can we use them in Grafana? I wrote them down here so as not to get confused, because there are quite a few of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cluster_status_green_maximum&lt;/code&gt;, &lt;code&gt;aws_es_cluster_status_yellow_maximum&lt;/code&gt;, &lt;code&gt;aws_es_cluster_status_red_maximum&lt;/code&gt;: you can create a single Stats panel&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_nodes_maximum&lt;/code&gt;: also some kind of stats panel - we know how many there should be, and we'll mark it red when there are fewer Data Nodes than there should be.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_searchable_documents_maximum&lt;/code&gt;: just for fun, we will show the number of documents in all indexes together in a graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cpuutilization_average&lt;/code&gt;: one graph per node, and some Stats with general information and different colors&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_free_storage_space_maximum&lt;/code&gt;: just Stats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cluster_index_writes_blocked_maximum&lt;/code&gt;: did not add to Grafana, only alert&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_jvmmemory_pressure_average&lt;/code&gt;: graph and stats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_old_gen_jvmmemory_pressure_average&lt;/code&gt;: somewhere nearby, also graph + Stats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_automated_snapshot_failure_maximum&lt;/code&gt;: this is just for alerting&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_5xx_maximum&lt;/code&gt;: both graph and Stats&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_iops_throttle_maximum&lt;/code&gt;: graph to see in comparison with other data such as CPU/Mem usage&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_throughput_throttle_maximum&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_high_swap_usage_maximum&lt;/code&gt;: both graph and Stats - graph, to see in comparison with CPU/disks&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_read_latency_average&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_write_latency_average&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_read_throughput_average&lt;/code&gt;: I didn't add it because there are too many graphs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_write_throughput_average&lt;/code&gt;: I didn't add it because there are too many graphs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_read_iops_average&lt;/code&gt;: a graph that is useful for understanding how the k-NN cache works - if there is not enough of it (and we tested on &lt;code&gt;t3.small.searc&lt;/code&gt;h with 2 gigabytes of total memory), then there will be a lot of reading from the disk.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_write_iops_average&lt;/code&gt;: similarly&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_thread_count_average&lt;/code&gt;: I didn't add it because it's pretty static and I didn't see any particularly useful information in it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_search_rate_average&lt;/code&gt;: also just a graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_search_latency&lt;/code&gt;: similarly, somewhere nearby&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_sys_memory_utilization_average&lt;/code&gt;: Well, it will constantly be around 90% until I remove it from Grafana, but I added it to alerts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_jvmgcyoung_collection_count_average&lt;/code&gt;: graph showing how often it is called&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_jvmgcold_collection_count_average&lt;/code&gt;: graph showing how often it is called&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_primary_write_rejected_average&lt;/code&gt;: graph, but I haven't added it yet because there are too many graphs - only alerts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_replica_write_rejected_average&lt;/code&gt;: graph, but I haven't added it yet because there are too many graphs - only alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;k-NN:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knncache_capacity_reached_maximum&lt;/code&gt;: only for warning alerts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knneviction_count_average&lt;/code&gt;: did not add, although it may be interesting&lt;/li&gt;
&lt;li&gt;`aws_es_knngraph_memory_usage_average: did not add&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knngraph_memory_usage_percentage_maximum&lt;/code&gt;: graph instead of aws_es_knngraph_memory_usage_average&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knngraph_query_errors_maximum&lt;/code&gt;: alert only&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knngraph_query_requests_sum&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knnhit_count_maximum&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_knnmiss_count_maximum&lt;/code&gt;: graph&lt;/li&gt;
&lt;li&gt;`aws_es_knntotal_load_time_sum: it would be nice to have a graph, but there is no space on the board&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  VictoriaMetrics/Prometheus &lt;code&gt;sum()&lt;/code&gt;, &lt;code&gt;avg()&lt;/code&gt; and max()`
&lt;/h3&gt;

&lt;p&gt;First, let’s recall what functions we have for data aggregation.&lt;/p&gt;

&lt;p&gt;With CloudWatch for OpenSearch, we will receive two main types: &lt;em&gt;counter&lt;/em&gt; and &lt;em&gt;gauge&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
$ curl -s localhost:9106/metrics | grep cpuutil&lt;/p&gt;
&lt;h1&gt;
  
  
  HELP aws_es_cpuutilization_average CloudWatch metric AWS/ES CPUUtilization Dimensions: [ClientId, DomainName, NodeId] Statistic: Average Unit: Percent
&lt;/h1&gt;
&lt;h1&gt;
  
  
  TYPE aws_es_cpuutilization_average gauge
&lt;/h1&gt;

&lt;p&gt;aws_es_cpuutilization_average{job="aws_es",instance="",domain_name="atlas-kb-prod-cluster",node_id="BzX51PLwSRCJ7GrbgB4VyA",client_id="492***148",} 10.0 1758099600000&lt;br&gt;
...&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The difference between them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;counter&lt;/strong&gt; : the value can only increase the value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gauge&lt;/strong&gt; : the value can increase and decrease&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here we have “&lt;code&gt;TYPE aws_es_cpuutilization_average gauge"&lt;/code&gt;, because CPU usage can both increase and decrease.&lt;/p&gt;

&lt;p&gt;See the excellent documentation &lt;a href="https://victoriametrics.com/blog/prometheus-monitoring-metrics-counters-gauges-histogram-summaries/" rel="noopener noreferrer"&gt;VictoriaMetrics — Prometheus Metrics Explained: Counters, Gauges, Histograms &amp;amp; Summaries&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;How can we use it in graphs?&lt;/p&gt;

&lt;p&gt;If we just look at the values, we have a set of labels here, each forming its own time series:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cpuutilization_average{node_id="BzX51PLwSRCJ7GrbgB4VyA"}&lt;/code&gt; == 9&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cpuutilization_average{node_id="IIEcajw5SfmWCXe_AZMIpA"}&lt;/code&gt; == 28&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aws_es_cpuutilization_average{node_id="lrsnwK1CQgumpiXfhGq06g"}&lt;/code&gt; == 8&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F10fhzk5u2n24wkkcq4l5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F10fhzk5u2n24wkkcq4l5.png" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;sum()&lt;/code&gt; without a label, we simply get the sum of all values:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2wkw22l0k7r83q6xolwk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2wkw22l0k7r83q6xolwk.png" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If we do &lt;code&gt;sum by (node_id)&lt;/code&gt;, we will get the value for a specific time series, which will coincide with the sample without &lt;code&gt;sum by ()&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7mq8jlrmsnsmpgc2kkfo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7mq8jlrmsnsmpgc2kkfo.png" width="800" height="531"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(&lt;em&gt;the meaning changes as I write and make inquiries&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;With `max() without filters, we simply obtain the maximum value selected from all the time series received:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvxsblkjlag0fw31n0xjw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvxsblkjlag0fw31n0xjw.png" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And with &lt;code&gt;avg()&lt;/code&gt; - the average value of all values, i.e., the sum of all values divided by the number of time series:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff4t5yuvmkol9vhjzce92.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff4t5yuvmkol9vhjzce92.png" width="800" height="539"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s calculate it ourselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(41+46+12)/3
33
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actually, the reason I decided to write about this separately is because even with &lt;code&gt;sum()&lt;/code&gt; and &lt;code&gt;by (node_id)&lt;/code&gt;, you can sometimes get the following results:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgutjlwd4xvf6dgal3odz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgutjlwd4xvf6dgal3odz.png" width="614" height="774"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Although without &lt;code&gt;sum()&lt;/code&gt; there are none:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5tylj9dw5qpdsoneb059.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5tylj9dw5qpdsoneb059.png" width="614" height="774"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And they happened because Pod was being recreated from CloudWatch Exporter at that moment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsjrr6ov208hcdsamp65f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsjrr6ov208hcdsamp65f.png" width="614" height="731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And at that moment, we were receiving data from the old pod and the new one.&lt;/p&gt;

&lt;p&gt;Therefore, the options here are to use either &lt;code&gt;max()&lt;/code&gt; or just &lt;code&gt;avg()&lt;/code&gt;. Although⁣ &lt;code&gt;max()&lt;/code&gt; is probably better, because we are interested in the "worst" indicators.&lt;/p&gt;

&lt;p&gt;Okay, now that we’ve figured that out, let’s get started on the dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cluster status
&lt;/h3&gt;

&lt;p&gt;Here, I would like to see all three values — Green, Yellow, and Red — on a single Stats panel.&lt;/p&gt;

&lt;p&gt;But since we don’t have if/else in Grafana, let’s make a workaround.&lt;/p&gt;

&lt;p&gt;We collect all three metrics and multiply the result of each by 1, 2, or 3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(aws_es_cluster_status_green_maximum) by (domain_name) * 1 +
sum(aws_es_cluster_status_yellow_maximum) by (domain_name) * 2 +
sum(aws_es_cluster_status_red_maximum) by (domain_name) * 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzc81mf8o3pkmwcc4hrq0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzc81mf8o3pkmwcc4hrq0.png" width="787" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Accordingly, if &lt;code&gt;aws_es_cluster_status_green_maximum&lt;/code&gt; == 1, then 1 * 1 == 1, and &lt;code&gt;aws_es_cluster_status_yellow_maximum&lt;/code&gt; == 0 and &lt;code&gt;aws_es_cluster_status_red_maximum&lt;/code&gt; will be == 0, then the multiplication will return 0.&lt;/p&gt;

&lt;p&gt;And if &lt;code&gt;aws_es_cluster_status_green_maximum&lt;/code&gt; becomes 0, but &lt;code&gt;aws_es_cluster_status_red_maximum&lt;/code&gt; is 1, then 1 * 2 equals 3, and based on the value 3, we will change the indicator in the Stats panel.&lt;/p&gt;

&lt;p&gt;And add Value mappings with text and colors:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdutc1ycrgk5ejczp2s71.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdutc1ycrgk5ejczp2s71.png" width="800" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Get the following result:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F298%2F0%2AqPS7pp_v22WMI59M.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F298%2F0%2AqPS7pp_v22WMI59M.png" width="298" height="157"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Nodes status
&lt;/h3&gt;

&lt;p&gt;It’s simple here — we know the required number, and we get the current one from &lt;code&gt;aws_es_nodes_maximum&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(aws_es_nodes_maximum) by (domain_name)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And again, using Value mappings, we set the values and colors:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F942%2F0%2Au0JXfDp8RVT58PJ9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F942%2F0%2Au0JXfDp8RVT58PJ9.png" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In case we ever increase the number of nodes and forget to update the value for “OK” here, we add a third status, ERR:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F295%2F0%2AQAPAAXBwrGQiX9nF.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F295%2F0%2AQAPAAXBwrGQiX9nF.png" width="295" height="153"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  CPUUtilization: Stats
&lt;/h3&gt;

&lt;p&gt;Here, we will make a cross-tabulation with the Gauge visualization type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avg(aws_es_cpuutilization_average) by (domain_name)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set Text size and Unit:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2Ahl6JhgYzwq82g6Zv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2Ahl6JhgYzwq82g6Zv.png" width="800" height="286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And Thresholds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F315%2F0%2A_VkW9lFBRDpHdy7c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F315%2F0%2A_VkW9lFBRDpHdy7c.png" width="315" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Description ChatGPT generates pretty well — useful for developers and for us in six months, or we can just take the description from &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-cloudwatchmetrics.html" rel="noopener noreferrer"&gt;AWS documentation&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The percentage of CPU usage for data nodes in the cluster. Maximum shows the node with the highest CPU usage. Average represents all nodes in the cluster.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Add the rest of the stats:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A2-ABqeGgk62ZpgzK.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A2-ABqeGgk62ZpgzK.png" width="800" height="54"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  CPUUtilization: Graph
&lt;/h3&gt;

&lt;p&gt;Here we will display a graph for the CPU of each node — the average over 5 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max(avg_over_time(aws_es_cpuutilization_average[5m])) by (node_id)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here is another example of how &lt;code&gt;sum()&lt;/code&gt; created spikes that did not actually exist:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F755%2F0%2Ad3d4p1Jgf4DcvUxi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F755%2F0%2Ad3d4p1Jgf4DcvUxi.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Therefore, we do &lt;code&gt;max()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Set Gradient mode == Opacity, and Unit == percent:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2AbBspuCXjuIRW-lzr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2AbBspuCXjuIRW-lzr.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Set Color scheme and Thresholds, enable Show thresholds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2AaHWTo0i2iquBAPWB.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2AaHWTo0i2iquBAPWB.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Data links, you can set a link to the DataNode Health page in the AWS Console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://us-east-1.console.aws.amazon.com/aos/home?region=us-east-1#opensearch/domains/atlas-kb-prod-cluster/data_Node/${__field.labels.node_id}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All available fields — Ctrl+Space:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F895%2F0%2ABhUGuRh-PWrpR3y5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F895%2F0%2ABhUGuRh-PWrpR3y5.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Actions seems to have appeared not so long ago. I haven’t used it yet, but it looks interesting — you can push something:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F721%2F0%2Aze1aDaM1wMfEsK_8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F721%2F0%2Aze1aDaM1wMfEsK_8.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  JVMMemoryPressure: Graph
&lt;/h3&gt;

&lt;p&gt;Here, we are interested in seeing whether memory usage “sticks” and how often the Garbage Collector is launched.&lt;/p&gt;

&lt;p&gt;The query is simple  —  you can do &lt;code&gt;max by (node_id)&lt;/code&gt;, but I just made a general picture for the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max(aws_es_jvmmemory_pressure_average)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the schedule is similar to the previous one:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AhDfHdXlfuZFf1uTk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AhDfHdXlfuZFf1uTk.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Description, add the explanation “when to worry”:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Represents the percentage of JVM heap in use (young + old generation).&lt;br&gt;&lt;br&gt;
Values below 75% are normal. Sustained pressure above 80% indicates frequent GC and potential performance degradation.&lt;br&gt;&lt;br&gt;
Values consistently &amp;gt; 85–90% mean heap exhaustion risk and may trigger ClusterIndexWritesBlocked — investigate immediately.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  JVMGCYoungCollectionCount and JVMGCOldCollectionCount
&lt;/h3&gt;

&lt;p&gt;A very useful graph to see how often Garbage Collects are triggered.&lt;/p&gt;

&lt;p&gt;In the query, we will use &lt;code&gt;increase[1m]&lt;/code&gt; to see how the value has changed in a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max(increase(aws_es_jvmgcyoung_collection_count_average[1m])) by (domain_name)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for Old Gen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max(increase(aws_es_jvmgcold_collection_count_average[1m])) by (domain_name)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unit — ops/sec, Decimals set to 0 to have only integer values:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APZEI5qtvVhCrt6Cm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APZEI5qtvVhCrt6Cm.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  KNNHitCount vs KNNMissCount
&lt;/h3&gt;

&lt;p&gt;Here, we will generate data for a second  — &lt;code&gt; rate()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(rate(aws_es_knnhit_count_average[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for Cache Miss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum(rate(aws_es_knnmiss_count_average[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unit ops/s, colors can be set via Overrides:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AEF1XXyg0ZtE4myo1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AEF1XXyg0ZtE4myo1.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The statistics here, by the way, are very mediocre — there are consistently a lot of cache misses, but we haven’t figured out why yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Final result
&lt;/h3&gt;

&lt;p&gt;We collect all the graphs and get something like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A-4Ld9JO_sTGhf8_o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A-4Ld9JO_sTGhf8_o.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;t3.small.search&lt;/code&gt; vs &lt;code&gt;t3.medium.search&lt;/code&gt; on graphs
&lt;/h3&gt;

&lt;p&gt;And here’s an example of how a lack of resources, primarily memory, is reflected in the graphs: we had &lt;code&gt;t3.medium.search&lt;/code&gt;, then we switched back to &lt;code&gt;t3.small.search&lt;/code&gt; to see how it would affect performance.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;t3.small.search&lt;/code&gt; is only 2 gigabytes of memory and 2 CPU cores.&lt;/p&gt;

&lt;p&gt;Of these 2 gigabytes of memory, 1 gigabyte was allocated to JVM Heap, 500 megabytes to k-NN memory, and 500 remained for other processes.&lt;/p&gt;

&lt;p&gt;Well, the results are quite expected:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F771%2F0%2AaT6RWS0ZdNDE8_9X.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F771%2F0%2AaT6RWS0ZdNDE8_9X.png" width="771" height="953"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Garbage Collectors started running constantly because it was necessary to clean up the memory that was lacking.&lt;/li&gt;
&lt;li&gt;Read IOPS increased because data was constantly being loaded from the disk to the JVM Heap Young and k-NN.&lt;/li&gt;
&lt;li&gt;Search Latency increased because not all data was in the cache, and I/O operations from the disk were pending.&lt;/li&gt;
&lt;li&gt;and CPU utilization jumped — because the CPU was loaded with Garbage Collectors and reading from the disk&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Creating Alerts
&lt;/h3&gt;

&lt;p&gt;You can also check out the recommendations from &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/cloudwatch-alarms.html" rel="noopener noreferrer"&gt;AWS — Recommended CloudWatch alarms for Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;OpenSearch ClusterStatus Yellow and OpenSearch ClusterStatus Red: here, simply if more than 0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: OpenSearch ClusterStatus Yellow
        expr: sum(aws_es_cluster_status_yellow_maximum) by (domain_name, node_id) &amp;gt; 0
        for: 1s
        labels:
          severity: warning
          component: backend
          environment: prod
        annotations:
          summary: 'OpenSearch ClusterStatus Yellow status detected'
          description: |-
            The primary shards for all indexes are allocated to nodes in the cluster, but replica shards for at least one index are not
            *OpenSearch Doman*: `{{ "{{" }} $labels.domain_name }}`
          grafana_opensearch_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/b2d2dabd-a6b4-4a8a-b795-270b3e200a2e/aws-opensearch-cluster-cloudwatch'

      - alert: OpenSearch ClusterStatus Red
        expr: sum(aws_es_cluster_status_red_maximum) by (domain_name, node_id) &amp;gt; 0
        for: 1s
        labels:
          severity: critical
          component: backend
          environment: prod
        annotations:
          summary: 'OpenSearch ClusterStatus RED status detected!'
          description: |-
            The primary and replica shards for at least one index are not allocated to nodes in the cluster
            *OpenSearch Doman*: `{{ "{{" }} $labels.domain_name }}`
          grafana_opensearch_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/b2d2dabd-a6b4-4a8a-b795-270b3e200a2e/aws-opensearch-cluster-cloudwatch'
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Through &lt;code&gt;labels&lt;/code&gt;, we have implemented alert routing in Opsgenie to the necessary Slack channels, and the annotation &lt;code&gt;grafana_opensearch_overview_url&lt;/code&gt; is used to add a link to Grafana in a Slack message:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F380%2F0%2A3tdmffpqyMNR-vjW.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F380%2F0%2A3tdmffpqyMNR-vjW.png" width="380" height="291"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OpenSearch CPUHigh — if more than 20% for 10 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- alert: OpenSearch CPUHigh
        expr: sum(aws_es_cpuutilization_average) by (domain_name, node_id) &amp;gt; 20
        for: 10m
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenSearch Data Node down — if the node is down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- alert: OpenSearch Data Node down
        expr: sum(aws_es_nodes_maximum) by (domain_name) &amp;lt; 3
        for: 1s
        labels:
          severity: critical
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aws_es_free_storage_space_maximum&lt;/code&gt; - we don't need it yet.&lt;/p&gt;

&lt;p&gt;OpenSearch Blocking Write — alert us if write blocks have started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: OpenSearch Blocking Write
        expr: sum(aws_es_cluster_index_writes_blocked_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the rest of the alerts I’ve added so far:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: OpenSearch AutomatedSnapshotFailure 
        expr: sum(aws_es_automated_snapshot_failure_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch 5xx Errors 
        expr: sum(aws_es_5xx_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch IopsThrottled
        expr: sum(aws_es_iops_throttle_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: warning
...
      - alert: OpenSearch ThroughputThrottled
        expr: sum(aws_es_throughput_throttle_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: warning
...
      - alert: OpenSearch SysMemoryUtilization High Warning
        expr: avg(aws_es_sys_memory_utilization_average) by (domain_name) &amp;gt;= 95
        for: 5m
        labels:
          severity: warning
...
      - alert: OpenSearch PrimaryWriteRejected High
        expr: sum(aws_es_primary_write_rejected_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch KNNGraphQueryErrors High
        expr: sum(aws_es_knngraph_query_errors_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: critical
...
      - alert: OpenSearch KNNCacheCapacityReached
        expr: sum(aws_es_knngraph_query_errors_maximum) by (domain_name) &amp;gt;= 1
        for: 1s
        labels:
          severity: warning
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As we use it, we’ll see what else we can add.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/aws-monitoring-aws-opensearch-service-cluster-with-cloudwatch/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>aws</category>
      <category>monitoring</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Terraform: creating an AWS OpenSearch Service cluster and users</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Tue, 30 Dec 2025 10:00:00 +0000</pubDate>
      <link>https://forem.com/aws-heroes/terraform-creating-an-aws-opensearch-service-cluster-and-users-4786</link>
      <guid>https://forem.com/aws-heroes/terraform-creating-an-aws-opensearch-service-cluster-and-users-4786</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" width="480" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the first part, we covered the basics of AWS OpenSearch Service in general and the types of instances for Data Nodes — &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;AWS: Getting Started with OpenSearch Service as a Vector Store&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the second part, we covered access, &lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/" rel="noopener noreferrer"&gt;AWS: Creating an OpenSearch Service Cluster and Configuring Authentication and Authorization&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Now let’s write Terraform code to create a cluster, users, and indexes.&lt;/p&gt;

&lt;p&gt;We will create the cluster in VPC and use the internal user database for authentication.&lt;/p&gt;

&lt;p&gt;But in VPC, you can’t… Because — surprise! — AWS Bedrock requires OpenSearch Managed Cluster to be public, not in VPC.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The OpenSearch Managed Cluster you provided is not supported because it is VPC protected. Your cluster must be behind a public network.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wrote to the AWS tech. support, and they said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;However, there is an ongoing product feature request (PFR) to have Bedrock KnowledgeBases support provisioned Open Search clusters in VPC.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And they suggest using Amazon OpenSearch Serverless, which we are actually running away from because the prices are ridiculous.&lt;/p&gt;

&lt;p&gt;The second problem that arose when I started writing resources &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/bedrockagent_knowledge_base" rel="noopener noreferrer"&gt;&lt;code&gt;bedrockagent_knowledge_base&lt;/code&gt;&lt;/a&gt; is that it does not support &lt;code&gt;storage_configuration with type&lt;/code&gt;OPENSEARCH_MANAGED`, only Serverless.&lt;/p&gt;

&lt;p&gt;But &lt;a href="https://github.com/hashicorp/terraform-provider-aws/pull/44060" rel="noopener noreferrer"&gt;Pull Request for this already exists&lt;/a&gt;, maybe someday they will approve it.&lt;br&gt;
&lt;em&gt;(&lt;em&gt;UPD&lt;/em&gt;: this was already merged)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So, we will create an OpenSearch Managed Service cluster with three indexes  —  Dev/Staging/Prod.&lt;/p&gt;

&lt;p&gt;The cluster will have three small data nodes, and each index will have 1 primary shard and 1 replica, because the project is small, and the data in our Production index on AWS OpenSearch Serverless, from which we want to migrate to AWS OpenSearch Service, is currently only 2 GiB, and is unlikely to grow significantly in the future.&lt;/p&gt;

&lt;p&gt;It would be good to create the cluster in our own Terraform module to make it easier to create some test environments, as I did for AWS EKS, but there isn’t much time for that right now, so we’ll just use tf files with a separate &lt;code&gt;prod.tfvars&lt;/code&gt; for variables.&lt;/p&gt;

&lt;p&gt;Maybe later I’ll write separately about transferring it to our own module, because it’s really convenient.&lt;/p&gt;

&lt;p&gt;In the next part, we’ll talk about monitoring, because our Production has already crashed once :-)&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Terraform files structure&lt;/li&gt;
&lt;li&gt;Project planning&lt;/li&gt;
&lt;li&gt;Creating a cluster&lt;/li&gt;
&lt;li&gt;Custom endpoint configuration&lt;/li&gt;
&lt;li&gt;Terraform Outputs&lt;/li&gt;
&lt;li&gt;Creating OpenSearch Users&lt;/li&gt;
&lt;li&gt;Error: elastic: Error 403 (Forbidden)&lt;/li&gt;
&lt;li&gt;Creating Internal Users&lt;/li&gt;
&lt;li&gt;Internal database users&lt;/li&gt;
&lt;li&gt;Adding IAM Users&lt;/li&gt;
&lt;li&gt;Creating AWS Bedrock IAM Roles and OpenSearch Role mappings&lt;/li&gt;
&lt;li&gt;Creating OpenSearch indexes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Terraform files structure
&lt;/h3&gt;

&lt;p&gt;The initial file and directory structure of the project is as follows:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
$ tree .&lt;br&gt;
.&lt;br&gt;
├── README.md&lt;br&gt;
└── terraform&lt;br&gt;
    ├── Makefile&lt;br&gt;
    ├── backend.tf&lt;br&gt;
    ├── data.tf&lt;br&gt;
    ├── envs&lt;br&gt;
    │ └── prod&lt;br&gt;
    │ └── prod.tfvars&lt;br&gt;
    ├── locals.tf&lt;br&gt;
    ├── outputs.tf&lt;br&gt;
    ├── providers.tf&lt;br&gt;
    ├── variables.tf&lt;br&gt;
    └── versions.tf&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;providers.tf&lt;/code&gt; - provider settings, currently only AWS, and through it we set the default tags:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
provider "aws" {&lt;br&gt;
  region = var.aws_region&lt;br&gt;
  default_tags {&lt;br&gt;
    tags = {&lt;br&gt;
      component = var.component&lt;br&gt;
      created-by = "terraform"&lt;br&gt;
      environment = var.environment&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;data.tf&lt;/code&gt;, we collect AWS Account ID, Availability Zones, VPC, and private subnets in which we will create a cluster in which we will eventually create a cluster:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
data "aws_caller_identity" "current" {}&lt;/p&gt;

&lt;p&gt;data "aws_availability_zones" "available" {&lt;br&gt;
  state = "available"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;data "aws_vpc" "eks_vpc" {&lt;br&gt;
  id = var.vpc_id&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;data "aws_subnets" "private" {&lt;br&gt;
  filter {&lt;br&gt;
    name = "vpc-id"&lt;br&gt;
    values = [var.vpc_id]&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;tags = {&lt;br&gt;
    subnet-type = "private"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;File &lt;code&gt;variables.tf&lt;/code&gt; with our default variables, then we will add new ones:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
variable "aws_region" {&lt;br&gt;
  type = string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;variable "project_name" {&lt;br&gt;
  description = "A project name to be used in resources"&lt;br&gt;
  type = string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;variable "component" {&lt;br&gt;
  description = "A team using this project (backend, web, ios, data, devops)"&lt;br&gt;
  type = string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;variable "environment" {&lt;br&gt;
  description = "Dev/Prod, will be used in AWS resources Name tag, and resources names"&lt;br&gt;
  type = string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;variable "vpc_id" {&lt;br&gt;
  type = string&lt;br&gt;
  description = "A VPC ID to be used to create OpenSearch cluster and its Nodes"&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Pass the values of variables through a separate &lt;code&gt;prod.tfvars&lt;/code&gt; file, then, if necessary, we can create a new environment through a file of the type &lt;code&gt;envs/test/test.tfvars&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
aws_region = "us-east-1"&lt;br&gt;
project_name = "atlas-kb"&lt;br&gt;
component = "backend"&lt;br&gt;
environment = "prod"&lt;br&gt;
vpc_id = "vpc-0fbaffe234c0d81ea"&lt;br&gt;
dns_zone = "prod.example.co"&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;Makefile&lt;/code&gt;, we simplify our local life:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h3&gt;
  
  
  PROD
&lt;/h3&gt;

&lt;p&gt;init-prod:&lt;br&gt;
  terraform init -reconfigure -backend-config="key=prod/atlas-knowledge-base-prod.tfstate"&lt;/p&gt;

&lt;p&gt;plan-prod:&lt;br&gt;
  terraform plan -var-file=envs/prod/prod.tfvars&lt;/p&gt;

&lt;p&gt;apply-prod:&lt;br&gt;
  terraform apply -var-file=envs/prod/prod.tfvars&lt;/p&gt;

&lt;h1&gt;
  
  
  destroy-prod:
&lt;/h1&gt;

&lt;h1&gt;
  
  
  terraform destroy -var-file=envs/prod/prod.tfvars
&lt;/h1&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;What files will be next?&lt;/p&gt;

&lt;p&gt;We will also have AWS Bedrock, which will need to be configured for access — we will do this through its IAM Role, and I will not write about Bedrock here — because it is a separate topic, and Terraform does not yet support &lt;code&gt;OPENSEARCH_MANAGED&lt;/code&gt;, so we did it manually, and then we will execute &lt;a href="https://rtfm.co.ua/en/terraform-using-import-and-some-hiden-pitfalls/" rel="noopener noreferrer"&gt;terraform import&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We will create indexes, users for our Backend API, and Bedrock IAM Role mappings in OpenSearch’s internal database through Terraform OpenSearch Provider to simplify OpenSearch Dashboards access.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project planning
&lt;/h3&gt;

&lt;p&gt;We can create a cluster from the Terraform resource &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain" rel="noopener noreferrer"&gt;&lt;code&gt;aws_opensearch_domain&lt;/code&gt;&lt;/a&gt;, or we can use ready-made modules, such as the &lt;a href="https://registry.terraform.io/modules/terraform-aws-modules/opensearch/aws/latest" rel="noopener noreferrer"&gt;opensearch&lt;/a&gt; from &lt;a href="https://www.linkedin.com/in/antonbabenko/" rel="noopener noreferrer"&gt;@Anton Babenko&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s take Anton’s module, because I use his modules a lot, and everything works great.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a cluster
&lt;/h3&gt;

&lt;p&gt;Examples — &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-opensearch/tree/master/examples" rel="noopener noreferrer"&gt;terraform-aws-opensearch/tree/master/examples&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Add a variable with cluster parameters to the &lt;code&gt;variables.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;variable "cluser_options" {&lt;br&gt;
  description = "A map of options to configure the OpenSearch cluster"&lt;br&gt;
  type = object({&lt;br&gt;
    instance_type = string&lt;br&gt;
    instance_count = number&lt;br&gt;
    volume_size = number&lt;br&gt;
    volume_type = string&lt;br&gt;
    engine_version = string&lt;br&gt;
    auto_software_update_enabled = bool&lt;br&gt;
  })&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And a value in &lt;code&gt;prod.tfvars&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;cluser_options = {&lt;br&gt;
  instance_type = "t3.small.search"&lt;br&gt;
  instance_count = 3&lt;br&gt;
  volume_size = 50&lt;br&gt;
  volume_type = "gp3"&lt;br&gt;
  engine_version = "OpenSearch_2.19"&lt;br&gt;
  auto_software_update_enabled = true&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;t3.small.search&lt;/code&gt; instances are the most minimal and sufficient for us at this time, although there are limitations for &lt;code&gt;t3&lt;/code&gt;, such as the &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/auto-tune.html" rel="noopener noreferrer"&gt;AWS OpenSearch Auto-tune&lt;/a&gt; feature not being supported.&lt;/p&gt;

&lt;p&gt;In general, &lt;code&gt;t3&lt;/code&gt; is not intended for production use cases. See also &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp.html" rel="noopener noreferrer"&gt;Operational best practices for Amazon OpenSearch Service&lt;/a&gt;, &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/supported-instance-types.html#latest-gen" rel="noopener noreferrer"&gt;Current generation instance types&lt;/a&gt;, and &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/limits.html" rel="noopener noreferrer"&gt;Amazon OpenSearch Service quotas&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I set the version here to 2.9, but 3.1 was added just a few days ago — see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version" rel="noopener noreferrer"&gt;Supported versions of Elasticsearch and OpenSearch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We take three nodes so that the cluster can select a cluster manager node if one node fails, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-multiaz.html" rel="noopener noreferrer"&gt;Dedicated master node distribution&lt;/a&gt;, &lt;a href="https://www.instaclustr.com/blog/learning-opensearch-from-scratch-part-2-digging-deeper/" rel="noopener noreferrer"&gt;Learning OpenSearch from scratch, part 2: Digging deeper&lt;/a&gt;, and &lt;a href="https://aws.amazon.com/blogs/big-data/enhance-stability-with-dedicated-cluster-manager-nodes-using-amazon-opensearch-service/" rel="noopener noreferrer"&gt;Enhance stability with dedicated cluster manager nodes using Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Contents of the &lt;code&gt;locals.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
locals {&lt;br&gt;
  # 'atlas-kb-prod'&lt;br&gt;
  env_name = "${var.project_name}-${var.environment}"&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Most of the &lt;code&gt;locals&lt;/code&gt; will be right here, but some that are very "local" to a particular code will be in the resource code files.&lt;/p&gt;

&lt;p&gt;Add the file &lt;code&gt;opensearcth_users.tf&lt;/code&gt; - for now, there is only a root user here, and the password is stored in AWS Parameter Store (instead of AWS Secrets Manager - "that's just how it happened historically“):&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h3&gt;
  
  
  ROOT
&lt;/h3&gt;

&lt;h1&gt;
  
  
  generate root password
&lt;/h1&gt;

&lt;h1&gt;
  
  
  waiting for write-only: &lt;a href="https://github.com/hashicorp/terraform-provider-aws/pull/43621" rel="noopener noreferrer"&gt;https://github.com/hashicorp/terraform-provider-aws/pull/43621&lt;/a&gt;
&lt;/h1&gt;

&lt;h1&gt;
  
  
  then will update it with the ephemeral type
&lt;/h1&gt;

&lt;p&gt;resource "random_password" "os_master_password" {&lt;br&gt;
  length = 16&lt;br&gt;
  special = true&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  store the root password in AWS Parameter Store
&lt;/h1&gt;

&lt;p&gt;resource "aws_ssm_parameter" "os_master_password" {&lt;br&gt;
  name = "/${var.environment}/${local.env_name}-root-password"&lt;br&gt;
  description = "OpenSearch cluster master password"&lt;br&gt;
  type = "SecureString"&lt;br&gt;
  value = random_password.os_master_password.result&lt;br&gt;
  overwrite = true&lt;br&gt;
  tier = "Standard"&lt;/p&gt;

&lt;p&gt;lifecycle {&lt;br&gt;
    ignore_changes = [value] # to prevent diff every time password is regenerated&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;data "aws_ssm_parameter" "os_master_password" {&lt;br&gt;
  name = "/${var.environment}/${local.env_name}-root-password"&lt;br&gt;
  with_decryption = true&lt;/p&gt;

&lt;p&gt;depends_on = [aws_ssm_parameter.os_master_password]&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Let’s write the &lt;code&gt;opensearch_cluster.tf&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;I left the config for VPC here for future reference and just as an example, although it will not be possible to transfer an already created cluster to VPC — you will have to create a new one, see &lt;strong&gt;Limitations&lt;/strong&gt; in the documentation &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html#vpc-limitations" rel="noopener noreferrer"&gt;Launching your Amazon OpenSearch Service domains within a VPC&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
module "opensearch" {&lt;br&gt;
  source = "terraform-aws-modules/opensearch/aws"&lt;br&gt;
  version = "~&amp;gt; 2.0.0"  &lt;/p&gt;

&lt;p&gt;# enable Fine-grained access control&lt;br&gt;
  # by using the internal user database, we'll simply access to the Dashboards&lt;br&gt;
  # for backend API Kubernetes Pods, will use Kubernetes Secrets with username:password from AWS Parameter Store&lt;br&gt;
  advanced_security_options = {&lt;br&gt;
    enabled = true&lt;br&gt;
    anonymous_auth_enabled = false&lt;br&gt;
    internal_user_database_enabled = true&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;master_user_options = {
  master_user_name = "os_root"
  master_user_password = data.aws_ssm_parameter.os_master_password.value
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;# can't be used with t3 instances&lt;br&gt;
  auto_tune_options = {&lt;br&gt;
    desired_state = "DISABLED"&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;# have three data nodes - t3.small.search nodes in two AZs&lt;br&gt;
  # will use 3 indexes - dev/stage/prod with 1 shard and 1 replica each&lt;br&gt;
  cluster_config = {&lt;br&gt;
    instance_count = var.cluser_options.instance_count&lt;br&gt;
    dedicated_master_enabled = false&lt;br&gt;
    instance_type = var.cluser_options.instance_type&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# put both data-nodes in different AZs
zone_awareness_config = {
  availability_zone_count = 2
}

zone_awareness_enabled = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;# the cluster's name&lt;br&gt;
  # 'atlas-kb-prod'&lt;br&gt;
  domain_name = "${local.env_name}-cluster"&lt;/p&gt;

&lt;p&gt;# 50 GiB for each Data Node&lt;br&gt;
  ebs_options = {&lt;br&gt;
    ebs_enabled = true&lt;br&gt;
    volume_type = var.cluser_options.volume_type&lt;br&gt;
    volume_size = var.cluser_options.volume_size&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;encrypt_at_rest = {&lt;br&gt;
    enabled = true&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;# latest for today:&lt;br&gt;
  # &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html#choosing-version&lt;/a&gt;&lt;br&gt;
  engine_version = var.cluser_options.engine_version&lt;/p&gt;

&lt;p&gt;# enable CloudWatch logs for Index and Search slow logs&lt;br&gt;
  # TODO: collect to VictoriaLogs or Loki, and create metrics and alerts&lt;br&gt;
  log_publishing_options = [&lt;br&gt;
    { log_type = "INDEX_SLOW_LOGS" },&lt;br&gt;
    { log_type = "SEARCH_SLOW_LOGS" },&lt;br&gt;
  ]&lt;/p&gt;

&lt;p&gt;ip_address_type = "ipv4"&lt;/p&gt;

&lt;p&gt;node_to_node_encryption = {&lt;br&gt;
    enabled = true&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;# allow minor version updates automatically&lt;br&gt;
  # will be performed during off-peak windows&lt;br&gt;
  software_update_options = {&lt;br&gt;
    auto_software_update_enabled = var.cluser_options.auto_software_update_enabled&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;# DO NOT use 'atlas-vpc-ops' VPC and its private subnets&lt;br&gt;
  # &amp;gt; "The OpenSearch Managed Cluster you provided is not supported because it is VPC protected. Your cluster must be behind a public network."&lt;br&gt;
  # vpc_options = {&lt;br&gt;
  # subnet_ids = data.aws_subnets.private.ids&lt;br&gt;
  # }&lt;/p&gt;

&lt;p&gt;# # VPC endpoint to access from Kubernetes Pods&lt;br&gt;
  # vpc_endpoints = {&lt;br&gt;
  # one = {&lt;br&gt;
  # subnet_ids = data.aws_subnets.private.ids&lt;br&gt;
  # }&lt;br&gt;
  # }&lt;/p&gt;

&lt;p&gt;# Security Group rules to allow access from the VPC only&lt;br&gt;
  # security_group_rules = {&lt;br&gt;
  # ingress_443 = {&lt;br&gt;
  # type = "ingress"&lt;br&gt;
  # description = "HTTPS access from VPC"&lt;br&gt;
  # from_port = 443&lt;br&gt;
  # to_port = 443&lt;br&gt;
  # ip_protocol = "tcp"&lt;br&gt;
  # cidr_ipv4 = data.aws_vpc.ops_vpc.cidr_block&lt;br&gt;
  # }&lt;br&gt;
  # }&lt;/p&gt;

&lt;p&gt;# Access policy&lt;br&gt;
  # necessary to allow access for AWS user to the Dashboards&lt;br&gt;
  access_policy_statements = [&lt;br&gt;
    {&lt;br&gt;
      effect = "Allow"&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  principals = [{
    type = "*"
    identifiers = ["*"]
  }]

  actions = ["es:*"]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;]&lt;/p&gt;

&lt;p&gt;# 'atlas-kb-ops-os-cluster'&lt;br&gt;
  tags = {&lt;br&gt;
    Name = "${var.project_name}-${var.environment}-os-cluster"&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Basically, everything is described in the comments, but in short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;enable &lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/#Fine-grained_access_control" rel="noopener noreferrer"&gt;fine-grained access control&lt;/a&gt; and a local user database&lt;/li&gt;
&lt;li&gt;three data nodes, each with 50 gigabytes of disk space, in different Availability Zones&lt;/li&gt;
&lt;li&gt;enable logs in CloudWatch&lt;/li&gt;
&lt;li&gt;create a cluster in private subnets&lt;/li&gt;
&lt;li&gt;allow access for everyone in the Domain Access Policy&lt;/li&gt;
&lt;li&gt;well, that’s it for now… we can’t use Security Groups because we’re not in VPC, but how do we create an IP-based policy? We don’t know CIDR Bedrock&lt;/li&gt;
&lt;li&gt;or in the &lt;code&gt;principals.identifiers&lt;/code&gt; we could add a limit on our IAM Users + Bedrock AIM Role&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run creating the cluster and go to have some tea, as this process will take around 20 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom endpoint configuration
&lt;/h3&gt;

&lt;p&gt;After creating the cluster, check access to the Dashboards. If everything is OK, add a custom endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;: Custom endpoints have their own quirks: in Terraform OpenSearch Provider, you need to use the custom endpoint URL, but in AWS Bedrock Knowledge Base, you need to use the default cluster URL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To do this, we need to create a certificate in AWS Certificate Manager, and add a new record in Route53.&lt;/p&gt;

&lt;p&gt;I expected a possible chicken-and-egg problem here, because Custom Endpoint settings depend on AWS ACM and a record in AWS Route53, and the record in AWS Route53 will depend on the cluster because it uses its endpoint.&lt;/p&gt;

&lt;p&gt;But no, if you create a new cluster with the settings described below, everything is created correctly: first, the certificate in AWS ACM, then the cluster with Custom Endpoint, then the record in Route53 with CNAME to the cluster default URL.&lt;/p&gt;

&lt;p&gt;Add a new &lt;code&gt;local&lt;/code&gt; - &lt;code&gt;os_custom_domain_name&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
locals {&lt;br&gt;
  # 'atlas-kb-prod'&lt;br&gt;
  env_name = "${var.project_name}-${var.environment}"&lt;br&gt;
  # 'opensearch.prod.example.co'&lt;br&gt;
  os_custom_domain_name = "opensearch.${var.dns_zone}"&lt;br&gt;
}&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Add the Route53 zone data retrieval to the &lt;code&gt;data.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;data "aws_route53_zone" "zone" {&lt;br&gt;
  name = var.dns_zone&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Add certificate creation and Route53 entry to the &lt;code&gt;opensearch_cluster.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h1&gt;
  
  
  TLS for the Custom Domain
&lt;/h1&gt;

&lt;p&gt;module "prod_opensearch_acm" {&lt;br&gt;
  source = "terraform-aws-modules/acm/aws"&lt;br&gt;
  version = "~&amp;gt; 6.0"&lt;/p&gt;

&lt;p&gt;# 'opensearch.example.co'&lt;br&gt;
  domain_name = local.os_custom_domain_name&lt;br&gt;
  zone_id = data.aws_route53_zone.zone.zone_id&lt;/p&gt;

&lt;p&gt;validation_method = "DNS"&lt;br&gt;
  wait_for_validation = true&lt;/p&gt;

&lt;p&gt;tags = {&lt;br&gt;
    Name = local.os_custom_domain_name&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;resource "aws_route53_record" "opensearch_domain_endpoint" {&lt;br&gt;
  zone_id = data.aws_route53_zone.zone.zone_id&lt;br&gt;
  name = local.os_custom_domain_name&lt;br&gt;
  type = "CNAME"&lt;br&gt;
  ttl = 300&lt;br&gt;
  records = [module.opensearch.domain_endpoint]&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;...&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And in the &lt;code&gt;module "opensearch"&lt;/code&gt;, add the custom endpoint settings:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
...&lt;br&gt;
  domain_endpoint_options = {&lt;br&gt;
    custom_endpoint_certificate_arn = module.prod_opensearch_acm.acm_certificate_arn&lt;br&gt;
    custom_endpoint_enabled = true&lt;br&gt;
    custom_endpoint = local.os_custom_domain_name&lt;br&gt;
    tls_security_policy = "Policy-Min-TLS-1-2-2019-07"&lt;br&gt;
  }&lt;br&gt;
...&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;terraform init&lt;/code&gt; and &lt;code&gt;terraform apply&lt;/code&gt;, check the settings:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ymu1102tbgsou4eqeu5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ymu1102tbgsou4eqeu5.png" width="538" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And check access to the Dashboards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Terraform Outputs
&lt;/h3&gt;

&lt;p&gt;Let’s add some outputs.&lt;/p&gt;

&lt;p&gt;For now, just for ourselves, but later we may use them in imports from other projects, see &lt;a href="https://rtfm.co.ua/en/terraform-terraform_remote_state-getting-outputs-from-other-state-files/" rel="noopener noreferrer"&gt;Terraform: terraform_remote_state — getting outputs from other state files&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
output "vpc_id" {&lt;br&gt;
  value = var.vpc_id&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "cluster_arn" {&lt;br&gt;
  value = module.opensearch.domain_arn&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "opensearch_domain_endpoint_cluster" {&lt;br&gt;
  value = "https://${module.opensearch.domain_endpoint}"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "opensearch_domain_endpoint_custom" {&lt;br&gt;
  value = "https://${local.os_custom_domain_name}"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "opensearch_root_username" {&lt;br&gt;
  value = "os_root"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;output "opensearch_root_user_password_secret_name" {&lt;br&gt;
  value = "/${var.environment}/${local.env_name}-root-password"&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating OpenSearch Users
&lt;/h3&gt;

&lt;p&gt;All that’s left now are users and indexes.&lt;/p&gt;

&lt;p&gt;We will have two types of users:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;regular users from the OpenSearch internal database — for our Backend API in Kubernetes (actually, we later switched to IAM Roles, which are mapped to the Backend via &lt;a href="https://rtfm.co.ua/aws-eks-pod-identities-zamina-irsa-sproshhuyemo-menedzhment-iam-dostupiv/" rel="noopener noreferrer"&gt;EKS Pod Identities&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;and users (IAM Role) for Bedrock — there will be three Knowledge Bases, each with its own IAM Role, for which we will need to add an OpenSearch Role and map it to IAM roles&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s start with regular users.&lt;/p&gt;

&lt;p&gt;Add a provider, in my case it is in the &lt;code&gt;versions.tf&lt;/code&gt; file:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
terraform {&lt;/p&gt;

&lt;p&gt;required_version = "~&amp;gt; 1.6"&lt;/p&gt;

&lt;p&gt;required_providers {&lt;br&gt;
    aws = {&lt;br&gt;
      source = "hashicorp/aws"&lt;br&gt;
      version = "~&amp;gt; 6.0"&lt;br&gt;
    }&lt;br&gt;
    opensearch = {&lt;br&gt;
      source = "opensearch-project/opensearch"&lt;br&gt;
      version = "~&amp;gt; 2.3"&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;code&gt;providers.tf&lt;/code&gt; file, describe access to the cluster:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;provider "opensearch" {&lt;br&gt;
  url = "https://${local.os_custom_domain_name}"&lt;br&gt;
  username = "os_root"&lt;br&gt;
  password = data.aws_ssm_parameter.os_master_password.value&lt;br&gt;
  healthcheck = false&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Error: elastic: Error 403 (Forbidden)
&lt;/h3&gt;

&lt;p&gt;Here is an important point about the &lt;code&gt;url&lt;/code&gt; value in the provider configuration. I wrote about it above, and now I will show you how it looks.&lt;/p&gt;

&lt;p&gt;First, in the &lt;code&gt;provider.url&lt;/code&gt;, I set it as &lt;code&gt;outputs&lt;/code&gt; of the module, i.e. &lt;code&gt;module.opensearch.domain_endpoint&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because of this, I got a 403 error when I tried to create users:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
...&lt;br&gt;
opensearch_user.os_kraken_dev_user: Creating...&lt;br&gt;
opensearch_role.os_kraken_dev_role: Creating...&lt;br&gt;
╷&lt;br&gt;
│ Error: elastic: Error 403 (Forbidden)&lt;br&gt;
│ &lt;br&gt;
│ with opensearch_user.os_kraken_dev_user,&lt;br&gt;
│ on opensearch_users.tf line 23, in resource "opensearch_user" "os_kraken_dev_user":&lt;br&gt;
│ 23: resource "opensearch_user" "os_kraken_dev_user" {&lt;br&gt;
│ &lt;br&gt;
╵&lt;br&gt;
╷&lt;br&gt;
│ Error: elastic: Error 403 (Forbidden)&lt;br&gt;
│ &lt;br&gt;
│ with opensearch_role.os_kraken_dev_role,&lt;br&gt;
│ on opensearch_users.tf line 30, in resource "opensearch_role" "os_kraken_dev_role":&lt;br&gt;
│ 30: resource "opensearch_role" "os_kraken_dev_role" {&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Thus, set the URL in the form of FQDN, which we did for Custom Endpoint, something like &lt;code&gt;"url = https://opensearch.exmaple.com"&lt;/code&gt; - and everything works well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating Internal Users
&lt;/h3&gt;

&lt;p&gt;Now for the users themselves.&lt;/p&gt;

&lt;p&gt;There will be three of them — &lt;em&gt;dev&lt;/em&gt;, &lt;em&gt;staging&lt;/em&gt;, &lt;em&gt;prod&lt;/em&gt;, each with access to the corresponding index.&lt;/p&gt;

&lt;p&gt;Here we will use &lt;a href="https://registry.terraform.io/providers/opensearch-project/opensearch/latest/docs/resources/user" rel="noopener noreferrer"&gt;&lt;code&gt;opensearch_user&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If the cluster is created in VPC, a VPN connection is required so that the provider can connect to the cluster.&lt;/p&gt;

&lt;p&gt;Add &lt;a href="https://rtfm.co.ua/en/terraform-introduction-to-data-types-primitives-and-complex/#list" rel="noopener noreferrer"&gt;list()&lt;/a&gt; to the &lt;code&gt;variables.tf&lt;/code&gt; with a list of environments:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;variable "app_environments" {&lt;br&gt;
  type = list(string)&lt;br&gt;
  description = "The Application's environments, to be used to created Dev/Staging/Prod DynamoDB tables, etc"&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And the value in &lt;code&gt;prod.tfvars&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;app_environments = [&lt;br&gt;
  "dev",&lt;br&gt;
  "staging",&lt;br&gt;
  "prod"&lt;br&gt;
]&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Internal database users
&lt;/h3&gt;

&lt;p&gt;At first, I planned to just use local users, and wrote this option in this post — let it be. Next, I will show how we did it in the end — with IAM Users and IAM Roles.&lt;/p&gt;

&lt;p&gt;In the file &lt;code&gt;opensearch_users.tf&lt;/code&gt;, add three passwords, three users, and three roles to which we map users in loops - each role with access to its own index:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;h3&gt;
  
  
  KRAKEN
&lt;/h3&gt;

&lt;p&gt;resource "random_password" "os_kraken_password" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  length = 16&lt;br&gt;
  special = true&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  store the root password in AWS Parameter Store
&lt;/h1&gt;

&lt;p&gt;resource "aws_ssm_parameter" "os_kraken_password" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;/p&gt;

&lt;p&gt;name = "/${var.environment}/${local.env_name}-kraken-${each.key}-password"&lt;br&gt;
  description = "OpenSearch cluster Backend Dev password"&lt;br&gt;
  type = "SecureString"&lt;br&gt;
  value = random_password.os_kraken_password[each.key].result&lt;br&gt;
  overwrite = true&lt;br&gt;
  tier = "Standard"&lt;/p&gt;

&lt;p&gt;lifecycle {&lt;br&gt;
    ignore_changes = [value] # to prevent diff every time password is regenerated&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  Create a user
&lt;/h1&gt;

&lt;p&gt;resource "opensearch_user" "os_kraken_user" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;/p&gt;

&lt;p&gt;username = "os_kraken_${each.key}"&lt;br&gt;
  password = random_password.os_kraken_password[each.key].result&lt;br&gt;
  description = "Backend EKS ${each.key} user"&lt;/p&gt;

&lt;p&gt;depends_on = [module.opensearch]&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  And a full user, role and role mapping example:
&lt;/h1&gt;

&lt;p&gt;resource "opensearch_role" "os_kraken_role" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;/p&gt;

&lt;p&gt;role_name = "os_kraken_${each.key}_role"&lt;br&gt;
  description = "Backend EKS ${each.key} role"&lt;/p&gt;

&lt;p&gt;cluster_permissions = [&lt;br&gt;
    "indices:data/read/msearch",&lt;br&gt;
    "indices:data/write/bulk*",&lt;br&gt;
   "indices:data/read/mget*"&lt;br&gt;
  ]&lt;br&gt;
  index_permissions {&lt;br&gt;
    index_patterns = ["kraken-kb-index-${each.key}"]&lt;br&gt;
    allowed_actions = ["*"]&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;depends_on = [module.opensearch]&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;cluster_permissions&lt;/code&gt;, we add permissions that are required for both the index level and the cluster level, because Bedrock did not work without them, see &lt;a href="https://docs.opensearch.org/latest/security/access-control/permissions/#cluster-wide-index-permissions" rel="noopener noreferrer"&gt;Cluster wide index permissions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Deploy and check in Dashboards:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffavv379fckl9srhrhrho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffavv379fckl9srhrhrho.png" width="525" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding IAM Users
&lt;/h3&gt;

&lt;p&gt;The idea here is the same, except that instead of regular users with a login:password for authentication, IAM and its Users &amp;amp;&amp;amp; Roles are used.&lt;/p&gt;

&lt;p&gt;More on the role for Bedrock later, but for now, let’s add user mapping.&lt;/p&gt;

&lt;p&gt;What we need to do is take a list of our Backend team users, give them an IAM Policy with access to OpenSearch, and then add mapping to a local role in the OpenSearch internal users database.&lt;/p&gt;

&lt;p&gt;For now, we can use the local role &lt;code&gt;all_access&lt;/code&gt;, although it would be better to write our own later. See &lt;a href="https://docs.opensearch.org/latest/security/access-control/users-roles/#predefined-roles" rel="noopener noreferrer"&gt;Predefined roles&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html#fgac-master-user" rel="noopener noreferrer"&gt;About the master user&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Add a new variable to the &lt;code&gt;variables.tf&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;variable "backend_team_users_arns" {&lt;br&gt;
  type = list(string)&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Its value in the &lt;code&gt;prod.tfvars&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;p&gt;backend_team_users_arns = [&lt;br&gt;
  "arn:aws:iam::492*&lt;strong&gt;148:user/arseny",&lt;br&gt;
  "arn:aws:iam::492&lt;/strong&gt;&lt;em&gt;148:user/misha",&lt;br&gt;
  "arn:aws:iam::492&lt;/em&gt;&lt;strong&gt;148:user/oleksii",&lt;br&gt;
  "arn:aws:iam::492&lt;/strong&gt;*148:user/vladimir",&lt;br&gt;
  "os_root"&lt;br&gt;
]&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Here, we had to mess around with the user &lt;code&gt;os_root&lt;/code&gt;, because otherwise it would be removed from the role.&lt;/p&gt;

&lt;p&gt;So, it’s better to make normal roles — but for MVP, it’s okay.&lt;/p&gt;

&lt;p&gt;And we add the mapping of these IAM Users to the role &lt;code&gt;all_access&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;h3&gt;
  
  
  BACKEND TEAM
&lt;/h3&gt;

&lt;p&gt;resource "opensearch_roles_mapping" "all_access_mapping" {&lt;br&gt;
  role_name = "all_access"&lt;/p&gt;

&lt;p&gt;users = var.backend_team_users_arns&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Deploy, check the &lt;code&gt;all_access&lt;/code&gt; role:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foo93gtrfzg4tqnuflzov.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foo93gtrfzg4tqnuflzov.png" width="800" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;: ChatGPT stubbornly insisted on adding IAM Users to Backend Roles, but no, and this is clearly stated in the documentation — you need to add them to Users, see&lt;/em&gt; &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html#fgac-more-masters" rel="noopener noreferrer"&gt;&lt;em&gt;Additional master users&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And for all the IAM Users we need to add an IAM policy with access.&lt;/p&gt;

&lt;p&gt;Again, for MVP, we can simply take the AWS managed policy &lt;a href="https://docs.aws.amazon.com/aws-managed-policy/latest/reference/AmazonOpenSearchServiceFullAccess.html" rel="noopener noreferrer"&gt;&lt;code&gt;AmazonOpenSearchServiceFullAccess&lt;/code&gt;&lt;/a&gt;, which is connected to the IAM Group:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F396ey0maakyzb45dbbt1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F396ey0maakyzb45dbbt1.png" width="800" height="744"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating AWS Bedrock IAM Roles and OpenSearch Role mappings
&lt;/h3&gt;

&lt;p&gt;We already have Bedrock, now just need to create new IAM Roles and map them to OpenSearch Roles.&lt;/p&gt;

&lt;p&gt;Add the &lt;code&gt;iam.tf&lt;/code&gt; file - describe the IAM Role and IAM Policy (Identity-based Policy for access to OpenSearch), also in a loop for each of the &lt;code&gt;var.app_environments&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h3&gt;
  
  
  MAIN ROLE FOR KNOWLEDGE BASE
&lt;/h3&gt;

&lt;h1&gt;
  
  
  grants permissions for AWS Bedrock to interact with other AWS services
&lt;/h1&gt;

&lt;p&gt;resource "aws_iam_role" "knowledge_base_role" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  name = "${var.project_name}-role-${each.key}-managed"&lt;br&gt;
  assume_role_policy = jsonencode({&lt;br&gt;
    Version = "2012-10-17"&lt;br&gt;
    Statement = [&lt;br&gt;
      {&lt;br&gt;
        Action = "sts:AssumeRole"&lt;br&gt;
        Effect = "Allow"&lt;br&gt;
        Principal = {&lt;br&gt;
          Service = "bedrock.amazonaws.com"&lt;br&gt;
        }&lt;br&gt;
        Condition = {&lt;br&gt;
          StringEquals = {&lt;br&gt;
            "aws:SourceAccount" = data.aws_caller_identity.current.account_id&lt;br&gt;
          }&lt;br&gt;
          ArnLike = {&lt;br&gt;
            # restricts the role to be assumed only by Bedrock knowledge base in the specified region&lt;br&gt;
            "aws:SourceArn" = "arn:aws:bedrock:${var.aws_region}:${data.aws_caller_identity.current.account_id}:knowledge-base/*"&lt;br&gt;
          }&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    ]&lt;br&gt;
  })&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  IAM policy for Knowledge Base to access OpenSearch Managed
&lt;/h1&gt;

&lt;p&gt;resource "aws_iam_policy" "knowledge_base_opensearch_policy" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  name = "${var.project_name}-kb-opensearch-policy-${each.key}-managed"&lt;br&gt;
  policy = jsonencode({&lt;br&gt;
    Version = "2012-10-17"&lt;br&gt;
    Statement = [&lt;br&gt;
      {&lt;br&gt;
        Effect = "Allow"&lt;br&gt;
        Action = [&lt;br&gt;
          "es:&lt;em&gt;",&lt;br&gt;
        ]&lt;br&gt;
        Resource = [&lt;br&gt;
          module.opensearch.domain_arn,&lt;br&gt;
          "${module.opensearch.domain_arn}/&lt;/em&gt;"&lt;br&gt;
        ]&lt;br&gt;
      }&lt;br&gt;
    ]&lt;br&gt;
  })&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;resource "aws_iam_role_policy_attachment" "knowledge_base_opensearch" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  role = aws_iam_role.knowledge_base_role[each.key].name&lt;br&gt;
  policy_arn = aws_iam_policy.knowledge_base_opensearch_policy[each.key].arn&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Next, in the &lt;code&gt;opensearch_users.tf&lt;/code&gt;, let's create:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;opensearch_role&lt;/code&gt;: with &lt;code&gt;cluster_permissions&lt;/code&gt; and &lt;code&gt;index_permissions&lt;/code&gt; for each index&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;locals&lt;/code&gt; with all the IAM Roles we created above&lt;/li&gt;
&lt;li&gt;and &lt;code&gt;opensearch_roles_mapping&lt;/code&gt; for each &lt;code&gt;opensearch_role.os_bedrock_roles&lt;/code&gt;, which we add to each &lt;code&gt;opensearch_rolevia backend_roles&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It looks something like this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
...&lt;/p&gt;

&lt;h4&gt;
  
  
  BEDROCK
&lt;/h4&gt;

&lt;p&gt;resource "opensearch_role" "os_bedrock_roles" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  role_name = "os_bedrock_${each.key}_role"&lt;br&gt;
  description = "Backend Bedrock KB ${each.key} role"&lt;/p&gt;

&lt;p&gt;cluster_permissions = [&lt;br&gt;
    "indices:data/read/msearch",&lt;br&gt;
    "indices:data/write/bulk*",&lt;br&gt;
    "indices:data/read/mget*"&lt;br&gt;
    ]&lt;/p&gt;

&lt;p&gt;index_permissions {&lt;br&gt;
    index_patterns = ["kraken-kb-index-${each.key}"]&lt;br&gt;
    allowed_actions = ["*"]&lt;br&gt;
  }&lt;/p&gt;

&lt;p&gt;depends_on = [module.opensearch]&lt;br&gt;
}&lt;/p&gt;

&lt;h1&gt;
  
  
  'aws_iam_role' is defined in iam.tf
&lt;/h1&gt;

&lt;p&gt;locals {&lt;br&gt;
  knowledge_base_role_arns = {&lt;br&gt;
    for env, role in aws_iam_role.knowledge_base_role :&lt;br&gt;
    env =&amp;gt; role.arn&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;resource "opensearch_roles_mapping" "os_bedrock_role_mappings" {&lt;br&gt;
  for_each = toset(var.app_environments)&lt;br&gt;
  role_name = opensearch_role.os_bedrock_roles[each.key].role_name&lt;/p&gt;

&lt;p&gt;backend_roles = [&lt;br&gt;
    local.knowledge_base_role_arns[each.key]&lt;br&gt;
  ]&lt;/p&gt;

&lt;p&gt;depends_on = [module.opensearch]&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Actually, this is where I encountered Bedrock access errors, which forced me to add &lt;code&gt;cluster_permissions&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The knowledge base storage configuration provided is invalid… Request failed: [security_exception] no permissions for [indices:data/read/msearch] and User [name=arn:aws:iam::492*&lt;strong&gt;148:role/kraken-kb-role-dev, backend_roles=[arn:aws:iam::492&lt;/strong&gt;*148:role/kraken-kb-role-dev], requestedTenant=null]&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Deploy, check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsn4op9v348u81ex5906s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsn4op9v348u81ex5906s.png" width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating OpenSearch indexes
&lt;/h3&gt;

&lt;p&gt;The provider already exists, so we’ll take the  &lt;a href="https://registry.terraform.io/providers/opensearch-project/opensearch/latest/docs/resources/index" rel="noopener noreferrer"&gt;&lt;code&gt;opensearch_index&lt;/code&gt;&lt;/a&gt; resource.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;locals&lt;/code&gt;, we write the index template - I just took it from the developers from the old configuration:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;br&gt;
locals {&lt;br&gt;
  # 'atlas-kb-prod'&lt;br&gt;
  env_name = "${var.project_name}-${var.environment}"&lt;br&gt;
  # 'opensearch.prod.example.co'&lt;br&gt;
  os_custom_domain_name = "opensearch.${var.dns_zone}"&lt;/p&gt;

&lt;p&gt;# index mappings&lt;/p&gt;

&lt;p&gt;os_index_mappings = &amp;lt;&amp;lt;-EOF&lt;br&gt;
    {&lt;br&gt;
      "dynamic_templates": [&lt;br&gt;
        {&lt;br&gt;
          "strings": {&lt;br&gt;
            "match_mapping_type": "string",&lt;br&gt;
            "mapping": {&lt;br&gt;
              "fields": {&lt;br&gt;
                "keyword": {&lt;br&gt;
                  "ignore_above": 8192,&lt;br&gt;
                  "type": "keyword"&lt;br&gt;
                }&lt;br&gt;
              },&lt;br&gt;
              "type": "text"&lt;br&gt;
            }&lt;br&gt;
          }&lt;br&gt;
        }&lt;br&gt;
      ],&lt;br&gt;
      "properties": {&lt;br&gt;
        "bedrock-knowledge-base-default-vector": {&lt;br&gt;
          "type": "knn_vector",&lt;br&gt;
          "dimension": 1024,&lt;br&gt;
          "method": {&lt;br&gt;
            "name": "hnsw",&lt;br&gt;
            "engine": "faiss",&lt;br&gt;
            "parameters": {&lt;br&gt;
              "m": 16,&lt;br&gt;
              "ef_construction": 512&lt;br&gt;
            },&lt;br&gt;
            "space_type": "l2"&lt;br&gt;
          }&lt;br&gt;
        },&lt;br&gt;
        "AMAZON_BEDROCK_METADATA": {&lt;br&gt;
          "type": "text",&lt;br&gt;
          "index": false&lt;br&gt;
        },&lt;br&gt;
        "AMAZON_BEDROCK_TEXT_CHUNK": {&lt;br&gt;
          "type": "text",&lt;br&gt;
          "index": true&lt;br&gt;
        }&lt;br&gt;
      }&lt;br&gt;
    }&lt;br&gt;
EOF&lt;br&gt;
}&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Create a file named &lt;code&gt;opensearch_indexes.tf&lt;/code&gt;. Add the indexes themselves - here, I decided not to use a loop, but to create separate Dev/Staging/Prod files directly:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;h1&gt;
  
  
  Dev Index
&lt;/h1&gt;

&lt;p&gt;resource "opensearch_index" "kb_vector_index_dev" {&lt;br&gt;
  name = "kraken-kb-index-dev"&lt;/p&gt;

&lt;p&gt;# enable approximate nearest neighbor search by setting index_knn to true&lt;br&gt;
  index_knn = true&lt;br&gt;
  index_knn_algo_param_ef_search = "512"&lt;br&gt;
  number_of_shards = "1"&lt;br&gt;
  number_of_replicas = "1"&lt;br&gt;
  mappings = local.os_index_mappings&lt;/p&gt;

&lt;p&gt;# When new documents are ingested into the Knowledge Base,&lt;br&gt;
  # OpenSearch automatically creates field mappings for new metadata fields under&lt;br&gt;
  # AMAZON_BEDROCK_METADATA. Since these fields are created outside of TF resource definitions,&lt;br&gt;
  # TF detects them as configuration drift and attempts to recreate the index to match its&lt;br&gt;
  # known state.&lt;br&gt;
  #&lt;br&gt;
  # This lifecycle rule prevents unnecessary index recreation by ignoring mapping changes&lt;br&gt;
  # that occur after initial deployment.&lt;br&gt;
  lifecycle {&lt;br&gt;
    ignore_changes = [mappings]&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;...&lt;br&gt;
`&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Deploy, check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvwobi88yyk8u53m8ixe7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvwobi88yyk8u53m8ixe7.png" width="800" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That’s basically it.&lt;/p&gt;

&lt;p&gt;Bedrock is already connected, everything is working.&lt;/p&gt;

&lt;p&gt;But it took a little bit of effort.&lt;/p&gt;

&lt;p&gt;And I’m sure it won’t be the last time :-)&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/terraform-creating-an-aws-opensearch-service-cluster-and-users/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>aws</category>
      <category>terraform</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Terraform: using Ephemeral Resources and Write-Only Attributes</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Mon, 29 Dec 2025 10:00:00 +0000</pubDate>
      <link>https://forem.com/setevoy/terraform-using-ephemeral-resources-and-write-only-attributes-56lg</link>
      <guid>https://forem.com/setevoy/terraform-using-ephemeral-resources-and-write-only-attributes-56lg</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" width="480" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ephemeral resources and write-only arguments appeared in Terraform a long time ago, back in version 1.10, but there was no opportunity to write about them in detail.&lt;/p&gt;

&lt;p&gt;The main idea behind them is not to leave “traces” in the state file, which is especially useful for passwords or tokens, because the data only exists during the execution of Terraform itself in its memory.&lt;/p&gt;

&lt;p&gt;However, there are certain limitations to their use — we’ll look at those later, but first, let’s see everything in action.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Example without ephemeral values and write-only arguments&lt;/li&gt;
&lt;li&gt;Using Write-Only Attributes&lt;/li&gt;
&lt;li&gt;Using Ephemeral Resources&lt;/li&gt;
&lt;li&gt;The “This output value is not declared as returning an ephemeral value” error&lt;/li&gt;
&lt;li&gt;The “Ephemeral outputs are not allowed in context of a root module” error&lt;/li&gt;
&lt;li&gt;Using values from Ephemeral resources&lt;/li&gt;
&lt;li&gt;Using Ephemeral Outputs&lt;/li&gt;
&lt;li&gt;Useful links&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Example without ephemeral values and write-only arguments
&lt;/h3&gt;

&lt;p&gt;Let’s start with the old scheme, without using ephemeral resources and write-only arguments  —  we will create a random password, the resource &lt;code&gt;aws_secretsmanager_secret&lt;/code&gt;, store this password in it, and get it from data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      component = "devops"
      created-by = "terraform"
      environment = "test"
    }
  }
}

### RESOURCES ###

# generate a random password
resource "random_password" "test_random_password" {
   length = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name = "db_password"
  description = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
  secret_string = random_password.test_random_password.result
}

### DATA SOURCES ###

# retrieve the AWS Secret value
data "aws_secretsmanager_secret_version" "test_aws_secret_data" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id

  depends_on = [aws_secretsmanager_secret_version.test_aws_secret_version]
}

### OUTPUTS ###

# get the random password value
output test_random_password {
  value = random_password.test_random_password.result
  sensitive = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = data.aws_secretsmanager_secret_version.test_aws_secret_data.secret_string
  sensitive = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;resource "random_password"&lt;/code&gt;: generate the password itself&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resource "aws_secretsmanager_secret"&lt;/code&gt;: create a new entry in AWS Secrets Manager&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resource "aws_secretsmanager_secret_version"&lt;/code&gt;: write the value from resource "random_password" to this Secret&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data “aws_secretsmanager_secret_version”&lt;/code&gt;: get the value from AWS Secrets Manager&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;output “test_random_password”&lt;/code&gt;: output the value from resource ‘random_password’&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;output “test_aws_secret”&lt;/code&gt;: output the value obtained from AWS Secrets Manager&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Execute &lt;code&gt;terraform init&lt;/code&gt; and &lt;code&gt;terraform apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

test_aws_secret = &amp;lt;sensitive&amp;gt;
test_random_password = &amp;lt;sensitive&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks OK  —  in &lt;code&gt;outputs&lt;/code&gt;, thanks to the &lt;code&gt;sensitive = true&lt;/code&gt;, nothing is displayed.&lt;/p&gt;

&lt;p&gt;But the password is in the state file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat terraform.tfstate
{
  ...
  "outputs": {
    "test_aws_secret": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    },
    "test_random_password": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    }
  },
...
  "resources": [
    {
      "mode": "data",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_data",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "random_password",
      "name": "test_random_password",
      ...
            "result": "1atcZYGR",
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let’s start hiding this data from the state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Write-Only Attributes
&lt;/h3&gt;

&lt;p&gt;Resource attributes with the suffix &lt;code&gt;_wo&lt;/code&gt; are "write-only" data, meaning that Terraform keeps them in memory during operations but does not store them anywhere.&lt;/p&gt;

&lt;p&gt;However, not all resources support these attributes. For example, in AWS RDS, you can pass a password via the &lt;code&gt;password_wo&lt;/code&gt; attribute of the &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_instance#password_wo-1" rel="noopener noreferrer"&gt;&lt;code&gt;aws_db_instance&lt;/code&gt;&lt;/a&gt; resource, but in &lt;code&gt;aws_opensearch_domain&lt;/code&gt; and its &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain#master_user_password-1" rel="noopener noreferrer"&gt;&lt;code&gt;master_user_password&lt;/code&gt;&lt;/a&gt; attribute to create a root user in the internal user database - not yet.&lt;/p&gt;

&lt;p&gt;Official documentation — &lt;a href="https://developer.hashicorp.com/terraform/language/resources/ephemeral/write-only" rel="noopener noreferrer"&gt;Use write-only arguments&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aws_secretsmanager_secret_version&lt;/code&gt; also supports write-only attributes - &lt;code&gt;secret_string_wo&lt;/code&gt; instead of &lt;code&gt;secret_string&lt;/code&gt;, and &lt;code&gt;secret_string_wo_version&lt;/code&gt; instead of &lt;code&gt;secret_string_version&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The use of &lt;code&gt;secret_string_wo_version&lt;/code&gt; is mandatory for &lt;code&gt;secret_string_wo&lt;/code&gt;, because since Terraform does not store password information, it will not know when to update it. To do this, we set a version that we increment each time we want to update the password.&lt;/p&gt;

&lt;p&gt;Edit the code, change the only &lt;code&gt;resource “aws_secretsmanager_secret_version”&lt;/code&gt; - set &lt;code&gt;secret_string_wo&lt;/code&gt; and &lt;code&gt;secret_string_wo_version&lt;/code&gt;, leaving the rest unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = random_password.test_random_password.result
  secret_string_wo_version = 1
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform apply&lt;/code&gt;, and check the state now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat terraform.tfstate
{
  ...
  "outputs": {
    "test_aws_secret": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    },
    "test_random_password": {
      "value": "1atcZYGR",
      "type": "string",
      "sensitive": true
    }
  },
...
  "resources": [
    {
      "mode": "data",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_data",
      ...
            "secret_string": "1atcZYGR",
...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "",
            "secret_string_wo": null,
            "secret_string_wo_version": 1,

...
    {
      "mode": "managed",
      "type": "random_password",
      "name": "test_random_password",
      ...
            "result": "1atcZYGR",
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have &lt;code&gt;managed.aws_secretsmanager_secret_version.test_aws_secret_version&lt;/code&gt; with no values for &lt;code&gt;secret_string&lt;/code&gt; and &lt;code&gt;secret_string_wo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Ephemeral Resources
&lt;/h3&gt;

&lt;p&gt;The idea behind “ephemeral” resources is the same as with write-only arguments — these resources only exist in Terraform’s memory during the execution of &lt;code&gt;terraform apply&lt;/code&gt; and are not stored in the state file.&lt;/p&gt;

&lt;p&gt;However, the use of such resources is limited:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you can refer to them in write-only arguments&lt;/li&gt;
&lt;li&gt;in other ephemeral resources&lt;/li&gt;
&lt;li&gt;in &lt;code&gt;locals&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;in ephemeral variables&lt;/li&gt;
&lt;li&gt;in providers, provisioners, and connections&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Documentation — &lt;a href="https://developer.hashicorp.com/terraform/language/ephemeral" rel="noopener noreferrer"&gt;Ephemeral block reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s edit our code and change &lt;code&gt;resource “random_password”&lt;/code&gt; to &lt;code&gt;ephemeral "random_password"&lt;/code&gt;, leave &lt;code&gt;resource “aws_secretsmanager_secret_version”&lt;/code&gt; - it will write the password to AWS Secrets Manager but will not store the value in state, and add a new resource - &lt;code&gt;ephemeral “aws_secretsmanager_secret_version”&lt;/code&gt;, through which we will get this password back in Terraform.&lt;/p&gt;

&lt;p&gt;At the same time, in the &lt;code&gt;secret_string_wo&lt;/code&gt; and in &lt;code&gt;output “test_random_password”&lt;/code&gt; we now refer to the password through &lt;em&gt;ephemeral&lt;/em&gt; - &lt;code&gt;ephemeral.random_password.test_random_password.result&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And in the &lt;code&gt;output “test_aws_secret”&lt;/code&gt; we also use &lt;code&gt;ephemeral.aws_secretsmanager_secret_version.test_aws_secret_data.secret_string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;data "aws_secretsmanager_secret_version"&lt;/code&gt; can be removed, because we will now get the password from the &lt;code&gt;ephemeral “aws_secretsmanager_secret_version”&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...

### RESOURCES ###

# generate a random password
ephemeral "random_password" "test_random_password" {
   length = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name = "db_password"
  description = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = ephemeral.random_password.test_random_password.result
  secret_string_wo_version = 1
}

### DATA SOURCES ###

# Retrieve the password from Secrets Manager (ephemeral)
ephemeral "aws_secretsmanager_secret_version" "test_aws_secret_version_ephemeral" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
}

# retrieve the AWS Secret value
# data "aws_secretsmanager_secret_version" "test_aws_secret_data" {
# secret_id = aws_secretsmanager_secret.test_aws_secret.id

# depends_on = [aws_secretsmanager_secret_version.test_aws_secret_version]
# }

### OUTPUTS ###

# get the random password value
output test_random_password {
  value = ephemeral.random_password.test_random_password.result
  sensitive = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sensitive = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The “This output value is not declared as returning an ephemeral value” error
&lt;/h3&gt;

&lt;p&gt;Execute &lt;code&gt;terraform apply&lt;/code&gt; and catch the first error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
│ Error: Ephemeral value not allowed
│ 
│ on main.tf line 53, in output "test_random_password":
│ 53: value = ephemeral.random_password.test_random_password.result
│ 
│ This output value is not declared as returning an ephemeral value, so it cannot be set to a result derived from an ephemeral value.
╵
╷
│ Error: Ephemeral value not allowed
│ 
│ on main.tf line 59, in output "test_aws_secret":
│ 59: value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
│ 
│ This output value is not declared as returning an ephemeral value, so it cannot be set to a result derived from an ephemeral value.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But even if we add the parameter &lt;code&gt;ephemeral = true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
### OUTPUTS ###

# get the random password value
output test_random_password {
  value = ephemeral.random_password.test_random_password.result
  sensitive = true
  ephemeral = true
}

# get the AWS Secret value
output "test_aws_secret" {
  value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sensitive = true
  ephemeral = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It still won’t work.&lt;/p&gt;

&lt;h3&gt;
  
  
  The “Ephemeral outputs are not allowed in context of a root module” error
&lt;/h3&gt;

&lt;p&gt;Now the error will look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
╷
│ Error: Ephemeral output not allowed
│ 
│ on main.tf line 52:
│ 52: output test_random_password {
│ 
│ Ephemeral outputs are not allowed in context of a root module
╵
╷
│ Error: Ephemeral output not allowed
│ 
│ on main.tf line 59:
│ 59: output "test_aws_secret" {
│ 
│ Ephemeral outputs are not allowed in context of a root module
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because Ephemeral outputs can only be used in modules — we’ll see how later.&lt;/p&gt;

&lt;p&gt;OK — for now, let’s just remove &lt;code&gt;Outputs&lt;/code&gt;, and now &lt;code&gt;terraform apply&lt;/code&gt; runs without any problems:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform apply
...
random_password.test_random_password: Refreshing state... [id=none]
ephemeral.random_password.test_random_password: Opening...
ephemeral.random_password.test_random_password: Opening complete after 0s
...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
...
ephemeral.random_password.test_random_password: Closing...
ephemeral.random_password.test_random_password: Closing complete after 0s
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Please note that for ephemeral resources, Terraform now performs &lt;em&gt;Opening&lt;/em&gt; and &lt;em&gt;Closing&lt;/em&gt; operations instead of &lt;em&gt;Reading&lt;/em&gt; and &lt;em&gt;Refreshing state&lt;/em&gt;. That is, it simply creates an object in memory, reads the resource into it, and then “closes” and removes it from memory.&lt;/p&gt;

&lt;p&gt;Let’s check the state file now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    {
      "mode": "managed",
      "type": "aws_secretsmanager_secret_version",
      "name": "test_aws_secret_version",
      ...
            "secret_string": "",
            "secret_string_wo": null,
            "secret_string_wo_version": 1,
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;resources &lt;code&gt;ephemeral “random_password”&lt;/code&gt; and &lt;code&gt;ephemeral “aws_secretsmanager_secret_version”&lt;/code&gt; are not in the state at all,&lt;/li&gt;
&lt;li&gt;and &lt;code&gt;managed.aws_secretsmanager_secret_version.test_aws_secret_version&lt;/code&gt; still has an empty field in &lt;code&gt;secret_string_wo&lt;/code&gt; because we made it write-only earlier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OK, but how do we use the password now? Because we removed &lt;code&gt;data “aws_secretsmanager_secret_version”&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using values from Ephemeral resources
&lt;/h3&gt;

&lt;p&gt;We have already seen an example of referencing Ephemeral resources above when we did &lt;code&gt;secret_string_wo = ephemeral.random_password.test_random_password.result&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Similarly, we can use &lt;code&gt;ephemeral.aws_secretsmanager_secret_version.db_password_wo_ephemeral.secret_string&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;As mentioned above, we cannot do this everywhere, but it is allowed in &lt;code&gt;providers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To verify this, let’s run PostgreSQL with our password (we’ll take it directly from AWS Console &amp;gt; AWS Secrets Manager):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxncpjtuiw9b4e3ryo4en.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxncpjtuiw9b4e3ryo4en.png" width="676" height="670"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Launch a container, to which we pass the variable &lt;code&gt;POSTGRES_PASSWORD="1atcZYGR"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker run --rm --name some-postgres -e POSTGRES_PASSWORD="1atcZYGR" -p 5432:5432 postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the provider to our code and use it to connect to the container, where we will create a test database.&lt;/p&gt;

&lt;p&gt;In the provider’s &lt;code&gt;password&lt;/code&gt; field we will use a value from the &lt;code&gt;ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...

### PostgreSQL Configuration

terraform {
  required_providers {
    postgresql = {
      source = "cyrilgdn/postgresql"
      version = "~&amp;gt; 1.20"
    }
  }
}

provider "postgresql" {
  host = "localhost"
  port = 5432
  username = "postgres"
  password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name = "demo_db"
  template = "template0"
  connection_limit = -1
  allow_connections = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform init&lt;/code&gt; and &lt;code&gt;terraform apply&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform init &amp;amp;&amp;amp; terraform apply
...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening complete after 1s
postgresql_database.demo_db: Creating...
postgresql_database.demo_db: Creation complete after 0s [id=demo_db]
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing...
ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing complete after 0s

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ export PGPASSWORD="1atcZYGR"
$ psql -h localhost -U postgres -c "\l"
                                                    List of databases
   Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges   
-----------+----------+----------+-----------------+------------+------------+--------+-----------+-----------------------
 demo_db | postgres | UTF8 | libc | en_US.utf8 | en_US.utf8 | | | 
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the same way, we could use an ephemeral resource via locals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
locals {
  db_password_local = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
}

provider "postgresql" {
  host = "localhost"
  port = 5432
  username = "postgres"
  password = local.db_password_local
  #password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name = "demo_db_via_local"
  template = "template0"
  connection_limit = -1
  allow_connections = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform apply
...
  # postgresql_database.demo_db will be updated in-place
  ~ resource "postgresql_database" "demo_db" {
        id = "demo_db"
      ~ name = "demo_db" -&amp;gt; "demo_db_via_local"
        # (10 unchanged attributes hidden)
    }
...
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the state file, the password is not visible anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat terraform.tfstate | grep 1atcZYGR | echo $?
127
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Using Ephemeral Outputs
&lt;/h3&gt;

&lt;p&gt;Above, we tried to use &lt;code&gt;output “test_aws_secret”&lt;/code&gt; with &lt;code&gt;ephemeral = true&lt;/code&gt;, but got the error "&lt;strong&gt;&lt;em&gt;Ephemeral outputs are not allowed in context of a root module"&lt;/em&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let’s try using it in our own module.&lt;/p&gt;

&lt;p&gt;Documentation — &lt;a href="https://developer.hashicorp.com/terraform/language/values/outputs#ephemeral-avoid-storing-values-in-state-or-plan-files" rel="noopener noreferrer"&gt;ephemeral — Avoid storing values in state or plan files&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s create a module &lt;code&gt;modules/secret_ephemeral&lt;/code&gt;, in which we will generate a password and save it in AWS Secrets Manager, and add Ephemeral Output.&lt;/p&gt;

&lt;p&gt;And in the root module, we will use &lt;code&gt;outputs&lt;/code&gt; of this module to get &lt;code&gt;ephemeral “aws_secretsmanager_secret_version”&lt;/code&gt;, as we did above.&lt;/p&gt;

&lt;p&gt;Let’s write the file &lt;code&gt;modules/secret_ephemeral/secret.tf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;### RESOURCES ###

# generate a random password
ephemeral "random_password" "test_random_password" {
   length = 8
   special = false
}

# create an AWS Secret resource
resource "aws_secretsmanager_secret" "test_aws_secret" {
  name = "db_password_via_module"
  description = "database passsword"
  recovery_window_in_days = 0
}

# create an AWS Secret value
resource "aws_secretsmanager_secret_version" "test_aws_secret_version" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
  #secret_string = random_password.test_random_password.result
  secret_string_wo = ephemeral.random_password.test_random_password.result
  secret_string_wo_version = 1
}

# Retrieve the password from Secrets Manager (ephemeral)
ephemeral "aws_secretsmanager_secret_version" "test_aws_secret_version_ephemeral" {
  secret_id = aws_secretsmanager_secret.test_aws_secret.id
}

output "password_ephemeral" {
  value = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  ephemeral = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the main file &lt;code&gt;main.tf&lt;/code&gt;, remove everything related to the password, add a module call, and in &lt;code&gt;locals&lt;/code&gt; use its &lt;code&gt;output&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...

### PostgreSQL Configuration

terraform {
  required_providers {
    postgresql = {
      source = "cyrilgdn/postgresql"
      version = "~&amp;gt; 1.20"
    }
  }
}

module "secret_ephemeral" {
  source = "./modules/secret_ephemeral"
}

locals {
  db_password_local = module.secret_ephemeral.password_ephemeral
}

provider "postgresql" {
  host = "localhost"
  port = 5432
  username = "postgres"
  password = local.db_password_local
  #password = ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral.secret_string
  sslmode = "disable"
}

resource "postgresql_database" "demo_db" {
  name = "demo_db_via"
  template = "template0"
  connection_limit = -1
  allow_connections = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, you need to create a password  —  run &lt;code&gt;terraform apply&lt;/code&gt; without &lt;code&gt;resource “postgresql_database”&lt;/code&gt;, and update the container launch with the new password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker run --rm --name some-postgres -e POSTGRES_PASSWORD="PHsfzcIx" -p 5432:5432 postgres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now our provider uses a password from the Ephemeral Output &lt;code&gt;module modules/secret_ephemeral&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Opening complete after 1s
postgresql_database.demo_db: Creating...
postgresql_database.demo_db: Creation complete after 0s [id=demo_db_via]
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing...
module.secret_ephemeral.ephemeral.aws_secretsmanager_secret_version.test_aws_secret_version_ephemeral: Closing complete after 0s

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the state, we still don’t have a password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat terraform.tfstate | grep PHsfzcIx | echo $?
127
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s basically it.&lt;/p&gt;

&lt;p&gt;It’s a shame that &lt;code&gt;aws_opensearch_domain&lt;/code&gt; doesn't support write-only. I wanted to use it for the root password :-(&lt;/p&gt;

&lt;p&gt;But there is already an issue on GitHub &lt;a href="https://github.com/hashicorp/terraform-provider-aws/issues/43509" rel="noopener noreferrer"&gt;Support ephemeral “write-only” argument for aws_opensearch_domain&lt;/a&gt;, and even a comment saying “&lt;em&gt;I have started working on this issue, and will submit a PR shortly”&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;And &lt;a href="https://github.com/hashicorp/terraform-provider-aws/pull/43621/files#diff-68e0a3f9a3d665361b3e6ddaa494ffb5164cbc9ec97e2b9b14350a2d7e6e7e47" rel="noopener noreferrer"&gt;in the pull request itself&lt;/a&gt;, you can even see how it’s implemented.&lt;/p&gt;

&lt;h3&gt;
  
  
  Useful links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://schimizu.com/securely-storing-credentials-in-terraform-with-ephemeral-blocks-and-write-only-attributes-6867826b9ef7" rel="noopener noreferrer"&gt;Securely storing credentials in Terraform with ‘Ephemeral Blocks’ and ‘Write-Only’ attributes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scalr.com/learning-center/understanding-ephemerality-in-terraform/" rel="noopener noreferrer"&gt;Understanding ephemerality in Terraform&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://thomasthornton.cloud/2025/04/24/ensuring-terraform-state-security-with-ephemeral-values-and-write-only-outputs/" rel="noopener noreferrer"&gt;Ensuring Terraform State Security with Ephemeral Values and Write-Only Outputs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/@amareswer/terraform-1-10-secure-secrets-with-ephemeral-values-a5041800ff58" rel="noopener noreferrer"&gt;Terraform 1.10: Secure Secrets with Ephemeral Values&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/terraform-using-ephemeral-resources-and-write-only-attributes/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>terraform</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Terraform: AWS EKS Terraform module update from version 20.x to version 21.x</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Thu, 18 Sep 2025 10:02:45 +0000</pubDate>
      <link>https://forem.com/setevoy/terraform-aws-eks-terraform-module-update-from-version-20x-to-version-21x-52im</link>
      <guid>https://forem.com/setevoy/terraform-aws-eks-terraform-module-update-from-version-20x-to-version-21x-52im</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frdcgbe0zxi1aeku3sf31.png" width="480" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AWS EKS Terraform module version &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/releases/tag/v21.0.0" rel="noopener noreferrer"&gt;v21.0.0&lt;/a&gt; added support for the &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/version-6-upgrade" rel="noopener noreferrer"&gt;AWS Provider Version 6&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Documentation — &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/docs/UPGRADE-21.0.md" rel="noopener noreferrer"&gt;here&amp;gt;&amp;gt;&amp;gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The main changes in the AWS EKS module are the replacement of IRSA with EKS Pod Identity for the Karpenter sub-module:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Native support for IAM roles for service accounts (IRSA) has been removed; EKS Pod Identity is now enabled by default&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Also, “&lt;em&gt;The &lt;code&gt;aws-auth&lt;/code&gt; sub-module has been removed&lt;/em&gt;”, but I personally removed it a long time ago.&lt;/p&gt;

&lt;p&gt;Some variables have also been renamed.&lt;/p&gt;

&lt;p&gt;I wrote about upgrading from version 19 to 20 in &lt;a href="https://rtfm.co.ua/en/terraform-eks-and-karpenter-upgrade-the-module-version-from-19-21-to-20-0/" rel="noopener noreferrer"&gt;Terraform: EKS and Karpenter — upgrade module version from 19.21 to 20.0&lt;/a&gt;, and this time we will follow the same path — change the module versions and see what breaks.&lt;/p&gt;

&lt;p&gt;I have a separate “Testing” environment for this, which I first roll out with the current versions of modules/providers, then update the code, deploy the upgrade, and when everything is fixed, I upgrade EKS Production (because we have one cluster on dev/staging/prod).&lt;/p&gt;

&lt;p&gt;In Karpenter’s own Helm chart, there seem to be no significant changes, although &lt;a href="https://github.com/aws/karpenter-provider-aws/releases/tag/v1.6.0" rel="noopener noreferrer"&gt;version 1.6&lt;/a&gt; has already been released. You can update it at the same time, but that’s for another time.&lt;/p&gt;

&lt;p&gt;Overall, the upgrade went smoothly, but there were two issues that required some debugging: a problem with the EC2 metadata for AWS Load Balancer Controller during the upgrade, and a problem with EKS Add-ons when creating a new cluster with AWS EKS Terraform module v21.x.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upgrade AWS EKS Terraform module
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Upgrade AWS Provider Version 6
&lt;/h3&gt;

&lt;p&gt;First, change the AWS Provider version — finally, because the open pool requests from &lt;a href="https://rtfm.co.ua/en/renovate-github-and-helm-charts-versions-management/" rel="noopener noreferrer"&gt;Renovate&lt;/a&gt; were annoying, and I couldn’t close them.&lt;/p&gt;

&lt;p&gt;It’s simple — just change the version to 6:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~&amp;gt; 6.0"
    }
  }
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the &lt;a href="https://developer.hashicorp.com/terraform/language/expressions/version-constraints" rel="noopener noreferrer"&gt;pessimistic constraint&lt;/a&gt; operator to allow upgrades of all minor versions.&lt;/p&gt;

&lt;p&gt;This will be considered both by Renovate, and when executing &lt;code&gt;terraform init -upgrade&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upgrade &lt;code&gt;terraform-aws-modules/eks/aws&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Let’s upgrade the EKS module version — change 20 to 21, also with the “&lt;code&gt;~&amp;gt;&lt;/code&gt;":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module "eks" {
  source = "terraform-aws-modules/eks/aws"
  version = "~&amp;gt; v21.0"
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And Karpenter too, I have it as a separate module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "~&amp;gt; v21.0"
  ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform init&lt;/code&gt;, and get the " &lt;strong&gt;&lt;em&gt;does not match configured version constraint&lt;/em&gt;&lt;/strong&gt;" error, I've already described it in the &lt;a href="https://rtfm.co.ua/en/?p=32870" rel="noopener noreferrer"&gt;Terraform: “no available releases match the given constraints&lt;/a&gt; post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform init
...
registry.terraform.io/hashicorp/aws 5.100.0 does not match configured version constraint &amp;gt;= 4.0.0, &amp;gt;= 4.36.0, &amp;gt;= 4.47.0, &amp;gt;= 5.0.0, ~&amp;gt; 5.14, &amp;gt;= 6.0.0
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; still contains the old version of the AWS provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat envs/test-1-33/.terraform.lock.hcl | grep -A 5 5.100
  version = "5.100.0"
  constraints = "&amp;gt;= 4.0.0, &amp;gt;= 4.33.0, &amp;gt;= 4.36.0, &amp;gt;= 4.47.0, &amp;gt;= 5.0.0, ~&amp;gt; 5.14, &amp;gt;= 5.95.0"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can drop the file and run &lt;code&gt;terraform init&lt;/code&gt; again, or you can run &lt;code&gt;terraform init -upgrade&lt;/code&gt; to pull all upgrades at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform init -upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; again - now everything is OK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ git diff .terraform.lock.hcl
diff --git a/terraform/envs/test-1-33/.terraform.lock.hcl b/terraform/envs/test-1-33/.terraform.lock.hcl
index bd44714..cb2eace 100644
--- a/terraform/envs/test-1-33/.terraform.lock.hcl
+++ b/terraform/envs/test-1-33/.terraform.lock.hcl
@@ -24,98 +24,85 @@ provider "registry.terraform.io/alekc/kubectl" {
 }

 provider "registry.terraform.io/hashicorp/aws" {
- version = "5.100.0"
- constraints = "&amp;gt;= 4.0.0, &amp;gt;= 4.33.0, &amp;gt;= 4.36.0, &amp;gt;= 4.47.0, &amp;gt;= 5.0.0, ~&amp;gt; 5.14, &amp;gt;= 5.95.0"
+ version = "6.7.0"
+ constraints = "&amp;gt;= 4.0.0, &amp;gt;= 4.36.0, &amp;gt;= 4.47.0, &amp;gt;= 5.0.0, &amp;gt;= 6.0.0, ~&amp;gt; 6.0"
   hashes = [
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s run &lt;code&gt;terraform plan&lt;/code&gt; and see what breaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Renamed variables в &lt;code&gt;terraform-aws-modules/eks/aws&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The first, as expected, were errors about missing variables, because they had been renamed in the module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ terraform plan -var-file=test-1-33.tfvars
...
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 34, in module "eks":
│ 34: cluster_name = "${var.env_name}-cluster"
│ 
│ An argument named "cluster_name" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 38, in module "eks":
│ 38: cluster_version = var.eks_version
│ 
│ An argument named "cluster_version" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 42, in module "eks":
│ 42: cluster_endpoint_public_access = var.eks_params.cluster_endpoint_public_access
│ 
│ An argument named "cluster_endpoint_public_access" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 46, in module "eks":
│ 46: cluster_enabled_log_types = var.eks_params.cluster_enabled_log_types
│ 
│ An argument named "cluster_enabled_log_types" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 50, in module "eks":
│ 50: cluster_addons = {
│ 
│ An argument named "cluster_addons" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/eks.tf line 148, in module "eks":
│ 148: cluster_security_group_name = "${var.env_name}-cluster-sg"
│ 
│ An argument named "cluster_security_group_name" is not expected here.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s go to the upgrade documentation and find out what the variables are now called:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cluster_name&lt;/code&gt; =&amp;gt; &lt;code&gt;name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_version&lt;/code&gt; =&amp;gt; &lt;code&gt;kubernetes_version&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_endpoint_public_access&lt;/code&gt; =&amp;gt; &lt;code&gt;endpoint_public_access&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_enabled_log_types&lt;/code&gt; =&amp;gt; &lt;code&gt;enabled_log_types&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_addons&lt;/code&gt; -&amp;gt; &lt;code&gt;addons&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cluster_security_group_name&lt;/code&gt; -&amp;gt; &lt;code&gt;security_group_name&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Although, in my opinion, the prefix &lt;code&gt;cluster_*&lt;/code&gt; would have been better, because we have &lt;code&gt;node_security_group_name&lt;/code&gt;, and there was &lt;code&gt;cluster_security_group_name&lt;/code&gt; - it is clear which parameter is for what.&lt;/p&gt;

&lt;p&gt;And now there is &lt;code&gt;node_security_group_name&lt;/code&gt; and "some" &lt;code&gt;security_group_name&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removed variables в terraform-aws-&lt;code&gt;modules/eks/aws//modules/karpenter&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;OK, edit the variable names in the main module code, run &lt;code&gt;terraform plan&lt;/code&gt; again - now we have errors for changes in the &lt;a href="https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest/submodules/karpenter" rel="noopener noreferrer"&gt;karpenter&lt;/a&gt; module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
 Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/karpenter.tf line 7, in module "karpenter":
│ 7: irsa_oidc_provider_arn = module.eks.oidc_provider_arn
│ 
│ An argument named "irsa_oidc_provider_arn" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/karpenter.tf line 8, in module "karpenter":
│ 8: irsa_namespace_service_accounts = ["karpenter:karpenter"]
│ 
│ An argument named "irsa_namespace_service_accounts" is not expected here.
╵
╷
│ Error: Unsupported argument
│ 
│ on ../../modules/atlas-eks/karpenter.tf line 14, in module "karpenter":
│ 14: enable_irsa = true
│ 
│ An argument named "enable_irsa" is not expected here.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They were removed because IRSA no longer exists — an EKS Pod Identity will now be created for Karpenter, see &lt;code&gt;[main.tf#L92&lt;/code&gt;](&lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/modules/karpenter/main.tf#L92" rel="noopener noreferrer"&gt;https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/modules/karpenter/main.tf#L92&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I wrote about EKS Pod Identities in &lt;a href="https://rtfm.co.ua/en/aws-eks-pod-identities-a-replacement-for-irsa-simplifying-iam-access-management/" rel="noopener noreferrer"&gt;AWS: EKS Pod Identities — a replacement for IRSA? Simplifying IAM access management&lt;/a&gt; and in &lt;a href="https://rtfm.co.ua/en/terraform-managing-eks-access-entries-and-eks-pod-identities/" rel="noopener noreferrer"&gt;Terraform: managing EKS Access Entries and EKS Pod Identities&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s remove them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  #irsa_oidc_provider_arn = module.eks.oidc_provider_arn
  #irsa_namespace_service_accounts = ["karpenter:karpenter"]
  #enable_irsa = true
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform plan&lt;/code&gt; again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Important: Karpenter’s EKS Identity Provider Namespace
&lt;/h3&gt;

&lt;p&gt;And here is an important point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  # module.atlas_eks.module.karpenter.aws_eks_pod_identity_association.karpenter[0] will be created
  + resource "aws_eks_pod_identity_association" "karpenter" {
      ...
      + namespace = "kube-system"
      + region = "us-east-1"
      + role_arn = "arn:aws:iam::492***148:role/KarpenterIRSA-atlas-eks-test-1-33-cluster"
      + service_account = "karpenter"
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;eks_pod_identity_association&lt;/code&gt; will be created for the Kubernetes Namespace &lt;code&gt;"kube-system"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you have Karpenter running in a different namespace, you need to specify it explicitly when calling the module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module "karpenter" {
  source = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "~&amp;gt; v21.0"

  cluster_name = module.eks.cluster_name
  namespace = "karpenter"
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise, Karpenter will broke, and the WorkerNode Group upgrade will fail because a Node will wait for the Karpenter, which will be in the &lt;code&gt;CrashLoopbackoff&lt;/code&gt;, and the Group upgrade will fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;eks_managed_node_groups&lt;/code&gt;: attribute "taints": map of object required
&lt;/h3&gt;

&lt;p&gt;Now there is an error with node group tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
│ The given value is not suitable for module.atlas_eks.module.eks.var.eks_managed_node_groups declared at .terraform/modules/atlas_eks.eks/variables.tf:1205,1-35: element "test-1-33-default": attribute "taints": map of object required.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Variable definitions now contain detailed object types in place of the previously used any type.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;See &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/compare/v20.37.2...v21.0.0#diff-aaea88c5bda7b25333fb85570ac1dd5167512fa91699dbedb738d180b2262b41L457" rel="noopener noreferrer"&gt;diff 20 vs 21&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2uzzk9p38z5ifs99qhe0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2uzzk9p38z5ifs99qhe0.png" width="800" height="76"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So now it should be map(object):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  type = map(object({
    key = string
    value = optional(string)
    effect = string
  }))
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And I have &lt;code&gt;taints&lt;/code&gt; currently been passed from a variable with an object &lt;code&gt;set(map(string))&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
variable "eks_managed_node_group_params" {
  description = "EKS Managed NodeGroups setting, one item in the map() per each dedicated NodeGroup"
  type = map(object({
    min_size = number
    max_size = number
    desired_size = number
    instance_types = list(string)
    capacity_type = string
    taints = set(map(string))
    max_unavailable_percentage = number
  }))
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the following values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
eks_managed_node_group_params = {
  default_group = {
    min_size = 1
    max_size = 1
    desired_size = 1
    instance_types = ["t3.medium"]
    capacity_type = "ON_DEMAND"
    taints = [
      {
        key = "CriticalAddonsOnly"
        value = "true"
        effect = "NO_SCHEDULE"
      },
      {
        key = "CriticalAddonsOnly"
        value = "true"
        effect = "NO_EXECUTE"
      }
    ]
    max_unavailable_percentage = 100
  }
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So what needs to be done is to change the declaration of the variable in my code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
variable "eks_managed_node_group_params" {
  description = "EKS Managed NodeGroups setting, one item in the map() per each dedicated NodeGroup"
  type = map(object({
    min_size = number
    max_size = number
    desired_size = number
    instance_types = list(string)
    capacity_type = string
    #taints = set(map(string))
    taints = optional(map(object({
      key = string
      value = optional(string)
      effect = string
    })))
    max_unavailable_percentage = number
  }))
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And update the values  —  add keys for &lt;code&gt;map{}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
eks_managed_node_group_params = {
  default_group = {
    min_size = 1
    max_size = 1
    desired_size = 1
    instance_types = ["t3.medium"]
    capacity_type = "ON_DEMAND"
    # taints = [
    # {
    # key = "CriticalAddonsOnly"
    # value = "true"
    # effect = "NO_SCHEDULE"
    # },
    # {
    # key = "CriticalAddonsOnly"
    # value = "true"
    # effect = "NO_EXECUTE"
    # }
    # ]
      taints = {
        critical_no_sched = {
          key = "CriticalAddonsOnly"
          value = "true"
          effect = "NO_SCHEDULE"
        },
        critical_no_exec = {
          key = "CriticalAddonsOnly"
          value = "true"
          effect = "NO_EXECUTE"
        }
      }
    max_unavailable_percentage = 100
  }
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform plan&lt;/code&gt; again, and now everything works without errors.&lt;/p&gt;

&lt;p&gt;Let’s deploy the updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploying changes
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;terraform apply&lt;/code&gt;, and now we have a new resource with EKS Pod Identity Association for Karpenter - &lt;code&gt;module.atlas_eks.module.karpenter.aws_eks_pod_identity_association.karpenter&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Froqxc8x4s1mn1o4p6vc1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Froqxc8x4s1mn1o4p6vc1.png" width="800" height="291"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which wasn’t here in the old cluster with v20.&lt;/p&gt;

&lt;h3&gt;
  
  
  ALB Controller error: “failed to fetch VPC ID from instance metadata”
&lt;/h3&gt;

&lt;p&gt;There was also a problem with AWS Load Balancer Controller, because after the upgrade it could not connect to IMDS, probably due to switching to v2, see &lt;a href="https://rtfm.co.ua/en/aws-security-instance-metadata-service-v1-vs-imds-v2-kubernetes-pod-and-docker-containers/" rel="noopener noreferrer"&gt;AWS: Instance Metadata Service v1 vs IMDS v2 and working with Kubernetes Pod and Docker containers&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
{"level":"error","ts":"2025-08-06T07:25:40Z"," logger":"setup","msg":"unable to initialize AWS cloud","error":"failed to get VPC ID: failed to fetch VPC ID from instance metadata: error in fetching vpc id through ec2 metadata: get mac metadata: operation error ec2imds: GetMetadata, canceled, context deadline exceeded"}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actually, we can just pass the parameters explicitly; see the documentation &lt;a href="https://github.com/kubernetes-sigs/aws-load-balancer-controller/blob/de50bdd80b227fb2ed940b30e33c224065d8c035/docs/deploy/installation.md#using-the-amazon-ec2-instance-metadata-server-version-2-imdsv2" rel="noopener noreferrer"&gt;Using the Amazon EC2 instance metadata server version 2 (IMDSv2)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;--aws-vpc-tag-key&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;optional flag — aws-vpc-tag-key if you have a different key for the tag other than “Name”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;First, let’s try setting the parameters manually to check that it works:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxxmvawiae32km4z93vph.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxxmvawiae32km4z93vph.png" width="430" height="129"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything is working now.&lt;/p&gt;

&lt;p&gt;Now the parameters for the Helm chart, see its &lt;a href="https://github.com/kubernetes-sigs/aws-load-balancer-controller/blob/main/helm/aws-load-balancer-controller/values.yaml#L163" rel="noopener noreferrer"&gt;values.yaml#L163&lt;/a&gt; — my controllers are installed from &lt;a href="https://github.com/aws-ia/terraform-aws-eks-blueprints-addons" rel="noopener noreferrer"&gt;aws-ia/eks-blueprints-addons/aws&lt;/a&gt; in Terraform when creating the cluster, so set here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    values = [
      &amp;lt;&amp;lt;-EOT
        replicaCount: 1
        region: ${var.aws_region}
        vpcId: ${var.vpc_id}
        tolerations:
        - key: CriticalAddonsOnly
          operator: Exists
      EOT
    ]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start the deployment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl2l3gh46n3out0854m1a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl2l3gh46n3out0854m1a.png" width="516" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue: Node Group Status CREATE_FAILED
&lt;/h3&gt;

&lt;p&gt;Here I will describe a problem that arose only when creating a new EKS cluster with module v21 — upgrading an existing cluster proceeds without these issues.&lt;/p&gt;

&lt;p&gt;Actually, here’s the problem: the cluster was created, everything seems OK, but it hangs for a long time on creating the Node Group, and then crashes with the error “ &lt;strong&gt;&lt;em&gt;unexpected state ‘CREATE_FAILED’&lt;/em&gt;&lt;/strong&gt; ”:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
╷
│ Error: waiting for EKS Node Group (atlas-eks-test-1-33-cluster:test-1-33-default-20250801112636765600000014) create: unexpected state 'CREATE_FAILED', wanted target 'ACTIVE'. last error: i-03f2c73c7211880f7: NodeCreationFailure: Unhealthy nodes in the kubernetes cluster
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgdqc5cnix06dkgssybly.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgdqc5cnix06dkgssybly.png" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Although there is an EC2 Auto Scaling Group created, and it has an EC2 up and running.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;So, the problem is that WorkerNode has been created but cannot connect to Kubernetes.&lt;/p&gt;

&lt;p&gt;The first thing that comes to mind is to check the Security Group, but everything appears to be correct here — all the rules are correct. I compared it with the current EKS cluster, which was created with AWS EKS Terraform module v20.x — everything is the same.&lt;/p&gt;

&lt;p&gt;Problem with IAM? EC2 doesn’t have permissions to access the cluster? Again, compare with the old cluster, and everything is OK.&lt;/p&gt;

&lt;h3&gt;
  
  
  “Check the logs, Billy!”
&lt;/h3&gt;

&lt;p&gt;The funny thing is that SSH is configured on all my EC2 instances, but only for nodes created with Karpenter, as I wrote in &lt;a href="https://rtfm.co.ua/en/aws-karpenter-and-ssh-for-kubernetes-workernodes/" rel="noopener noreferrer"&gt;AWS: Karpenter and SSH for Kubernetes WorkerNodes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The current problem arose in the “default” NodeGroup, where various controllers are launched.&lt;/p&gt;

&lt;p&gt;So, let’s connect via the AWS Console and select Connect:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgmc3pp27b2b2c20p3ciz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgmc3pp27b2b2c20p3ciz.png" width="800" height="42"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then, in &lt;em&gt;EC2 Instance Connect&lt;/em&gt;, select “&lt;em&gt;Connect using a Private IP&lt;/em&gt;” and select an existing &lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/connect-using-eice.html" rel="noopener noreferrer"&gt;EC2 Instance Connect Endpoint&lt;/a&gt; or quickly create a new one.&lt;/p&gt;

&lt;p&gt;Set the username — for Amazon Linux, it is &lt;code&gt;ec2-user&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5vwsv4cypngch5niqoww.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5vwsv4cypngch5niqoww.png" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And let’s look at the logs:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsaj5nece568i95b36p3d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsaj5nece568i95b36p3d.png" width="800" height="72"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  “Container runtime network not ready — cni plugin not initialized”
&lt;/h3&gt;

&lt;p&gt;Actually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Aug 01 13:26:04 ip-10-0-48-198.ec2.internal kubelet[1619]: E0801 13:26:04.989799 1619 kubelet.go:3126] "Container runtime network not ready" networkReady="NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wow…&lt;/p&gt;

&lt;p&gt;Okay, what’s the situation with VPC CNI?&lt;/p&gt;

&lt;p&gt;Let’s go check out EKS Add-ons, and…&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe3np5dz2fi9a9cfq5dwb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe3np5dz2fi9a9cfq5dwb.png" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It’s completely empty.&lt;/p&gt;

&lt;p&gt;Let’s look at the log &lt;code&gt;terraform apply&lt;/code&gt; - and we see "&lt;em&gt;Read complete&lt;/em&gt;", but there is no "&lt;em&gt;Creating...&lt;/em&gt;":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module.atlas_eks.module.eks.data.aws_eks_addon_version.this["vpc-cni"]: Read complete after 0s [id=vpc-cni]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s check if there are any containers on the node — maybe there are some errors?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqeor4gv4e3lat5xd80qy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqeor4gv4e3lat5xd80qy.png" width="559" height="103"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wow, once again…&lt;/p&gt;

&lt;p&gt;Nothing at all.&lt;/p&gt;

&lt;p&gt;Even then, I went back to GitHub Issues and searched for “&lt;em&gt;addon&lt;/em&gt;” and found this issue: &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/issues/3446" rel="noopener noreferrer"&gt;Managed EKS Node Groups boot without CNI, but addon is added after node group&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Actually, yes  —  the problem arose due to the absence of the &lt;code&gt;before_compute&lt;/code&gt; parameter.&lt;/p&gt;

&lt;p&gt;Although it’s a little strange, because it was &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/releases/tag/v19.9.0" rel="noopener noreferrer"&gt;added in version v19.9&lt;/a&gt;, the last time I deployed a cluster from scratch was with v20, and this problem did not occur.&lt;/p&gt;

&lt;p&gt;Even more, when I created the Testing cluster from the master branch, where none of the updates described here have been applied, and module version v20 is still used — everything is working without any problems.&lt;/p&gt;

&lt;p&gt;And in &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/compare/v20.37.2...v21.0.0#diff-aaea88c5bda7b25333fb85570ac1dd5167512fa91699dbedb738d180b2262b41L457" rel="noopener noreferrer"&gt;diff 20 vs 21&lt;/a&gt; I don’t see any significant changes related to &lt;code&gt;before_compute&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;However, since this only applies to creating a new cluster, we do not need to add before_compute when simply upgrading. But if you do add it, the add-ons will be recreated.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;before_compute itself&lt;/code&gt; was added to allow specifying which addons to create before WorkerNodes and which after. See &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/blob/master/main.tf#L797" rel="noopener noreferrer"&gt;&lt;code&gt;main.tf#L797&lt;/code&gt;&lt;/a&gt; and comments to &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks/pull/2478" rel="noopener noreferrer"&gt;PR #2478&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Add as in the examples &lt;a href="https://github.com/terraform-aws-modules/terraform-aws-eks?tab=readme-ov-file#eks-managed-node-group" rel="noopener noreferrer"&gt;EKS Managed Node Group&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    vpc-cni = {
      addon_version = var.eks_addon_versions.vpc_cni
      before_compute = true
      configuration_values = jsonencode({
        env = {
          ENABLE_PREFIX_DELEGATION = "true"
          WARM_PREFIX_TARGET = "1"
          AWS_VPC_K8S_CNI_EXTERNALSNAT = "true"
        }
      })
    }
    aws-ebs-csi-driver = {
      addon_version = var.eks_addon_versions.aws_ebs_csi_driver
      service_account_role_arn = module.ebs_csi_irsa_role.iam_role_arn
    }
    eks-pod-identity-agent = {
      addon_version = var.eks_addon_versions.eks_pod_identity_agent
      before_compute = true
    }
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;terraform apply&lt;/code&gt; again, and here it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module.atlas_eks.module.eks.aws_eks_addon.before_compute["vpc-cni"]: Creating...
...
module.atlas_eks.module.eks.aws_eks_addon.before_compute["vpc-cni"]: Creation complete after 46s [id=atlas-eks-test-1-33-cluster:vpc-cni]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the AWS Console:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ful51xq6b3hst723mv0ul.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ful51xq6b3hst723mv0ul.png" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;NodeGroup created without errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
module.atlas_eks.module.eks.module.eks_managed_node_group["test-1-33-default"].aws_eks_node_group.this[0]: Still creating... [01m40s elapsed]
module.atlas_eks.module.eks.module.eks_managed_node_group["test-1-33-default"].aws_eks_node_group.this[0]: Creation complete after 1m49s [id=atlas-eks-test-1-33-cluster:test-1-33-default-20250801142042855800000003]
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/terraform-aws-eks-terraform-module-update-from-version-20-x-to-version-21/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>terraform</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Kubernetes: PVC in a StatefulSet, and the “Forbidden updates to statefulset spec” error</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Wed, 17 Sep 2025 12:36:49 +0000</pubDate>
      <link>https://forem.com/setevoy/kubernetes-pvc-in-a-statefulset-and-the-forbidden-updates-to-statefulset-spec-error-5007</link>
      <guid>https://forem.com/setevoy/kubernetes-pvc-in-a-statefulset-and-the-forbidden-updates-to-statefulset-spec-error-5007</guid>
      <description>&lt;h3&gt;
  
  
  Kubernetes: PVC in StatefulSet, and the “Forbidden updates to statefulset spec” error
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We have a &lt;a href="https://github.com/VictoriaMetrics/helm-charts/tree/master/charts/victoria-logs-single" rel="noopener noreferrer"&gt;VictoriaLogs&lt;/a&gt; Helm chart with a PVC size of 30 GB, which is no longer enough for us, and we need to increase it.&lt;/p&gt;

&lt;p&gt;But the problem is that &lt;code&gt;.spec.volumeClaimTemplates[*].spec.resources.requests.storage&lt;/code&gt; in STS is immutable, that is, we can't just change the size through &lt;code&gt;values.yaml&lt;/code&gt; file, because it will lead to the error &lt;strong&gt;&lt;em&gt;"Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden&lt;/em&gt;&lt;/strong&gt;".&lt;/p&gt;

&lt;p&gt;The chart values now look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;victoria-logs-single:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 30Gi
    retentionPeriod: 7d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And with the default type of StatefulSet in the chart, the &lt;code&gt;volumeClaimTemplates&lt;/code&gt; is used to create PVCs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...  
volumeClaimTemplates:
    - apiVersion: v1
      kind: PersistentVolumeClaim
      metadata:
        name: server-volume
        ...
      spec:
        ...
        resources:
          requests:
            storage: {{ $app.persistentVolume.size }}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If instead of STS there was a Deployment type, then in the VictoriaLogs chart this would lead to the creation of a separate PVC  —  see the &lt;a href="https://github.com/VictoriaMetrics/helm-charts/blob/master/charts/victoria-logs-single/templates/pvc.yaml#L3C1-L3C81" rel="noopener noreferrer"&gt;&lt;code&gt;pvc.yaml&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You could simply create a separate PVC yourself and connect it through the &lt;a href="https://github.com/VictoriaMetrics/helm-charts/blob/master/charts/victoria-logs-single/values.yaml#L167" rel="noopener noreferrer"&gt;&lt;code&gt;existingClaim&lt;/code&gt;&lt;/a&gt; value, but you already have a PersistentVolume, and you don't want to create a new one and migrate data (although you can if you need to, see &lt;a href="https://rtfm.co.ua/en/victoriametrics-migrating-vmsingle-and-victorialogs-data-between-kubernetes-cluster/" rel="noopener noreferrer"&gt;VictoriaMetrics: migrating VMSingle and VictoriaLogs data between Kubernetes clusters&lt;/a&gt;, but there will be a down time), so let's see how we can solve this differently - without deleting Pods and without stopping the service.&lt;/p&gt;

&lt;h3&gt;
  
  
  storageClassName and AllowVolumeExpansion
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;storageClas&lt;/code&gt; used to create a Persistent Volume must support &lt;code&gt;AllowVolumeExpansion&lt;/code&gt; - see &lt;a href="https://kubernetes.io/docs/concepts/storage/storage-classes/#allow-volume-expansion" rel="noopener noreferrer"&gt;Volume expansion&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe storageclass gp2-retain
Name: gp2-retain
...
Provisioner: kubernetes.io/aws-ebs
Parameters: &amp;lt;none&amp;gt;
AllowVolumeExpansion: True
MountOptions: &amp;lt;none&amp;gt;
ReclaimPolicy: Retain
VolumeBindingMode: WaitForFirstConsumer
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create this &lt;code&gt;storageClass&lt;/code&gt; when creating an EKS cluster from a simple manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
resource "kubectl_manifest" "storageclass_gp2_retain" {

  yaml_body = &amp;lt;&amp;lt;YAML
    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: gp2-retain
    provisioner: kubernetes.io/aws-ebs
    reclaimPolicy: Retain
    allowVolumeExpansion: true
    volumeBindingMode: WaitForFirstConsumer
  YAML
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Although there is a dedicated &lt;a href="https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/storage_class" rel="noopener noreferrer"&gt;&lt;code&gt;storage_class&lt;/code&gt;&lt;/a&gt; resource for Terraform, and would be better to use it instead for the &lt;code&gt;kubectl_manifest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;kubernetes.io/aws-ebs&lt;/code&gt; driver is already deprecated (OMG, &lt;a href="https://aws.amazon.com/blogs/containers/amazon-ebs-csi-driver-is-now-generally-available-in-amazon-eks-add-ons/" rel="noopener noreferrer"&gt;since Kubernetes 1.17&lt;/a&gt;!), it's time to update to &lt;code&gt;ebs.csi.aws.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But we’ll fix this later, right now the goal is to simply increase the disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing the issue
&lt;/h3&gt;

&lt;p&gt;For the test, let’s write our own STS with &lt;code&gt;volumeClaimTemplates&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: demo-sts
spec:
  serviceName: demo-sts-svc
  replicas: 1
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
        - name: app
          image: busybox
          command: ["sh", "-c", "sleep 3600"]
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp2-retain
        resources:
          requests:
            storage: 1Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;volumeClaimTemplates&lt;/code&gt;, set the &lt;code&gt;storageClassName&lt;/code&gt; and the size to 1 gigabyte.&lt;/p&gt;

&lt;p&gt;Deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-sts-pvc.yaml 
statefulset.apps/demo-sts created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the PVC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data-demo-sts-0 Bound pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3 1Gi RWO gp2-retain &amp;lt;unset&amp;gt; 15s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, if we want to increase the size via &lt;code&gt;volumeClaimTemplates&lt;/code&gt; from 1Gi to 2Gi:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  volumeClaimTemplates:
    ...
        resources:
          requests:
            storage: 2Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we get an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-sts-pvc.yaml 
The StatefulSet "demo-sts" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;But we can get around this very easily:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;edit the PVC manually   — set a new size&lt;/li&gt;
&lt;li&gt;delete STS with the &lt;code&gt;--cascade=orphan&lt;/code&gt; - see &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/use-cascading-deletion/#set-orphan-deletion-policy" rel="noopener noreferrer"&gt;Delete owner objects and orphan dependents&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;create STS again&lt;/li&gt;
&lt;li&gt;…&lt;/li&gt;
&lt;li&gt;profit!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let’s try it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;: before changing disks, don’t forget about backups!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Edit the PVC manually —  change &lt;code&gt;resources.requests.storage&lt;/code&gt; from 1Gi to 2Gi:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favh7wrurqnhk8v516xau.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favh7wrurqnhk8v516xau.png" width="266" height="145"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Check the Events of this PVC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe pvc data-demo-sts-0
...
  Normal ExternalExpanding 40s volume_expand CSI migration enabled for kubernetes.io/aws-ebs; waiting for external resizer to expand the pvc
  Normal Resizing 40s external-resizer ebs.csi.aws.com External resizer is resizing volume pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3
  Normal FileSystemResizeRequired 35s external-resizer ebs.csi.aws.com Require file system resize of volume on node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after a few more seconds, it’s done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  Normal FileSystemResizeSuccessful 19s kubelet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;CAPACITY&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
data-demo-sts-0 Bound pvc-31a9a547-7547-4d34-bb2d-2c7015b9e0f3 2Gi RWO gp2-retain &amp;lt;unset&amp;gt; 4m7s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;2Gi&lt;/code&gt;, everything is OK.&lt;/p&gt;

&lt;p&gt;And now we also have 2 gigabytes in the Pod itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk exec -ti demo-sts-0 -- df -h /data
Filesystem Size Used Available Use% Mounted on
/dev/nvme7n1 1.9G 24.0K 1.9G 0% /data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if we try to deploy the changes to &lt;code&gt;volumeClaimTemplates.spec.resources.requests.storage&lt;/code&gt; again, we will still get an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-sts-pvc.yaml 
The StatefulSet "demo-sts" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'revisionHistoryLimit', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, delete the STS itself, but leave all its dependent objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl delete statefulset demo-sts --cascade=orphan 
statefulset.apps "demo-sts" deleted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check if the Pod is alive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pod
NAME READY STATUS RESTARTS AGE
demo-sts-0 1/1 Running 0 3m13s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now we just create STS again, with a new value in the &lt;code&gt;volumeClaimTemplates.spec.resources.requests.storage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-sts-pvc.yaml 
statefulset.apps/demo-sts created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/kubernetes-pvc-v-statefulset-and-the-forbidden-updates-to-statefulset-spec-error/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Kubernetes: what are the Kubernetes Operator and CustomResourceDefinition</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Tue, 16 Sep 2025 07:27:07 +0000</pubDate>
      <link>https://forem.com/setevoy/kubernetes-what-are-the-kubernetes-operator-and-customresourcedefinition-26id</link>
      <guid>https://forem.com/setevoy/kubernetes-what-are-the-kubernetes-operator-and-customresourcedefinition-26id</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Perhaps everyone has used operators in Kubernetes, for example, &lt;a href="https://github.com/zalando/postgres-operator" rel="noopener noreferrer"&gt;PostgreSQL operator&lt;/a&gt;, &lt;a href="https://docs.victoriametrics.com/operator/" rel="noopener noreferrer"&gt;VictoriaMetrics Operator&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But what’s going on under the hood? How and to what are CustomResourceDefinition (CRD) applied, and what is an “operator”?&lt;/p&gt;

&lt;p&gt;And finally, what is the difference between a Kubernetes Operator and a Kubernetes Controller?&lt;/p&gt;

&lt;p&gt;In the previous part — &lt;a href="https://rtfm.co.ua/kubernetes-kubernetes-api-api-groups-crd-ta-etcd/" rel="noopener noreferrer"&gt;Kubernetes:&lt;/a&gt;&lt;a href="https://rtfm.co.ua/en/kubernetes-kubernetes-api-api-groups-crds-and-the-etcd/" rel="noopener noreferrer"&gt;Kubernetes APIs, API Groups, CRDs, etcd&lt;/a&gt; — we dug a little deeper into how the Kubernetes API works and what a CRD is, and now we can try to write our own micro-operator, a simple MVP, and use it as an example to understand the details.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes Controller vs Kubernetes Operator&lt;/li&gt;
&lt;li&gt;What is: Kubernetes Controller&lt;/li&gt;
&lt;li&gt;What is: Kubernetes Operator&lt;/li&gt;
&lt;li&gt;Kubernetes Operator frameworks&lt;/li&gt;
&lt;li&gt;Creating a CustomResourceDefinition&lt;/li&gt;
&lt;li&gt;Creating a Kubernetes Operator with Kopf&lt;/li&gt;
&lt;li&gt;Resource templates: Kopf and Kubebuilder&lt;/li&gt;
&lt;li&gt;And what about in real operators?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kubernetes Controller vs Kubernetes Operator
&lt;/h3&gt;

&lt;p&gt;So, what is the main difference between Controllers and Operators?&lt;/p&gt;

&lt;h3&gt;
  
  
  What is: Kubernetes Controller
&lt;/h3&gt;

&lt;p&gt;Simply put, a &lt;em&gt;Controller&lt;/em&gt; is just some service that monitors resources in a cluster and brings their state in line with how this state is described in the database  — &lt;code&gt; etcd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Kubernetes, we have a set of default controllers — Core Controllers within the &lt;a href="https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/" rel="noopener noreferrer"&gt;Kube Controller Manager&lt;/a&gt;, such as the ReplicaSet Controller, which checks the number of pods in the Deployment against the replicas value, or the Deployment Controller, which controls the creation and update of ReplicaSets, or the PersistentVolume Controller and PersistentVolumeClaim Binder for working with disks, etc.&lt;/p&gt;

&lt;p&gt;In addition to these default controllers, you can create your own controller or use an existing one, such as ExternalDNS Controller. These are examples of custom controllers.&lt;/p&gt;

&lt;p&gt;Controllers work in a &lt;strong&gt;&lt;em&gt;control loop&lt;/em&gt;&lt;/strong&gt;  — a cyclic process in which they constantly check the resources assigned to them — either to change existing resources in the system or to respond to the addition of new ones.&lt;/p&gt;

&lt;p&gt;During each check*&lt;em&gt;&lt;em&gt;(reconciliation loop&lt;/em&gt;&lt;/em&gt;*), the Controller compares the &lt;em&gt;current state&lt;/em&gt; of the resource and compares it with the &lt;em&gt;desired state &lt;/em&gt;— that is, the parameters specified in its manifest when the resource was created or updated.&lt;/p&gt;

&lt;p&gt;If the desired state does not correspond to the current state, the controller performs the necessary actions to bring these states into alignment.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is: Kubernetes Operator
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Kubernetes Operator&lt;/em&gt;, in turn, is a kind of “controller on steroids”: in fact, Operator is a Custom Controller in the sense that it has its own service in the form of a Pod that communicates with the Kubernetes API to receive and update information about resources.&lt;/p&gt;

&lt;p&gt;But if ordinary controllers work with “default” resource types (Pod, Endpoint Slice, Node, PVC), then for Operator we describe our own custom resources using a manifest with Custom Resource.&lt;/p&gt;

&lt;p&gt;And how these resources will look like and what parameters they will have — we set through CustomResourceDefinition which are written to the Kubernetes database and added to the Kubernetes API, and thus the Kubernetes API allows our custom Controller to operate with these resources.&lt;/p&gt;

&lt;p&gt;That is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Controller&lt;/strong&gt; is a component, a service, and &lt;strong&gt;Operator&lt;/strong&gt; is a combination of one or more custom Controllers and corresponding CRDs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controller&lt;/strong&gt;  — responds to changes in resources, and &lt;strong&gt;Operator&lt;/strong&gt;  — adds new types of resources + controller that controls these resources&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kubernetes Operator frameworks
&lt;/h3&gt;

&lt;p&gt;There are several solutions that simplify the creation of operators.&lt;/p&gt;

&lt;p&gt;The main ones are &lt;a href="https://book.kubebuilder.io/introduction" rel="noopener noreferrer"&gt;Kubebuilder&lt;/a&gt;, a framework for creating controllers in Go, and &lt;a href="https://kopf.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;Kopf&lt;/a&gt;, a framework in Python.&lt;/p&gt;

&lt;p&gt;There is also the &lt;a href="https://sdk.operatorframework.io/" rel="noopener noreferrer"&gt;Operator SDK&lt;/a&gt;, which allows you to work with controllers even with Helm, without code.&lt;/p&gt;

&lt;p&gt;At first, I was thinking of doing it in bare Go, without any frameworks, to better understand how everything works under the hood — but this post started to turn into 95% Golang.&lt;/p&gt;

&lt;p&gt;And since the main idea of the post was to show conceptually what a Kubernetes Operator is, what role CustomResourceDefinitions play, and how they interact with each other and allow you to manage resources, I decided to use &lt;a href="https://kopf.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;Kopf&lt;/a&gt; because it’s very simple and quite suitable for these purposes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a CustomResourceDefinition
&lt;/h3&gt;

&lt;p&gt;Let’s start with writing the CRD.&lt;/p&gt;

&lt;p&gt;Actually, CustomResourceDefinition is just a description of what fields our custom resource will have so that the controller can use them through the Kubernetes API to create real resources — whether they are some resources in Kubernetes itself, or external ones like AWS Load Balancer or AWS Route 53.&lt;/p&gt;

&lt;p&gt;What we will do: we will write a CRD that will describe the &lt;code&gt;MyApp&lt;/code&gt; resource, and this resource will have fields for the Docker image and a custom field with some text that will then be written to the Kubernetes Pod logs.&lt;/p&gt;

&lt;p&gt;Kubernetes documentation on CRD — &lt;a href="https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/" rel="noopener noreferrer"&gt;Extend the Kubernetes API with CustomResourceDefinitions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Create the file &lt;code&gt;myapp-crd.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.demo.rtfm.co.ua
spec:
  group: demo.rtfm.co.ua
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image:
                  type: string
                banner: 
                  type: string
                  description: "Optional banner text for the application"
  scope: Namespaced
  names:
    plural: myapps
    singular: myapp
    kind: MyApp
    shortNames:
      - ma
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;spec.group: demo.rtfm.co.ua&lt;/code&gt;: create a new API Group, all resources of this type will be available at &lt;code&gt;/apis/demo.rtfm.co.ua/...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;versions&lt;/code&gt;: list of versions of the new resource&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;name.v1&lt;/code&gt;: we will have an only one version&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;served: true&lt;/code&gt;: add a new resource to the Kube API - you can do &lt;code&gt;kubectl get myapp&lt;/code&gt; (&lt;code&gt;GET /apis/demo.rtfm.co.ua/v1/myapps&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;storage: true&lt;/code&gt;: this version will be used for storage in &lt;code&gt;etcd&lt;/code&gt; (if several versions are described, only one should be with &lt;code&gt;storage: true&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;schema&lt;/code&gt;:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;openAPIV3Schema&lt;/code&gt;: describe the API scheme according to the &lt;a href="https://swagger.io/specification/" rel="noopener noreferrer"&gt;OpenAPI v3&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type: object&lt;/code&gt;: describe an object with nested fields (key: value)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;properties&lt;/code&gt;: what fields the object will have&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;spec&lt;/code&gt;: what we can use in YAML manifests when creating&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type: object&lt;/code&gt; - describe the following fields:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;properties&lt;/code&gt;:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image.type: string&lt;/code&gt;: a Docker image&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;banner.type: string&lt;/code&gt;: our custom field through which we will add some entry to the resource logs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scope: Namespace&lt;/code&gt;d: all resources of this type will exist in a specific Kubernetes Namespace&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;names&lt;/code&gt;:&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;plural: myapps&lt;/code&gt;: the resources will be available through &lt;code&gt;/apis/demo.rtfm.co.ua/v1/namespaces/&amp;lt;ns&amp;gt;/myapps/&lt;/code&gt;, and how we can "access" the resource (&lt;code&gt;kubectl get myapp&lt;/code&gt;), used in RBAC where you need to specify &lt;code&gt;resources:["myapps"]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;singular: myapp&lt;/code&gt;: alias for convenience&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;shortNames:[ma]&lt;/code&gt;: short alias for convenience&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s start Minikube:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ minikube start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the CRD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f myapp-crd.yaml 
customresourcedefinition.apiextensions.k8s.io/myapps.demo.rtfm.co.ua created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s look at the Groups API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$kubectl api-versions 
...
demo.rtfm.co.ua/v1 
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a new resource in this API Group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl api-resources --api-group=demo.rtfm.co.ua
NAME SHORTNAMES APIVERSION NAMESPACED KIND
myapps ma demo.rtfm.co.ua/v1 true MyApp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OK  —  we have created a CRD, and now we can even create a CustomResource (CR).&lt;/p&gt;

&lt;p&gt;Create the file &lt;code&gt;myapp-example-resource.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: demo.rtfm.co.ua/v1 # matches the CRD's group and version
kind: MyApp # kind from the CRD's 'spec.names.kind'
metadata:
  name: example-app # name of this custom resource
  namespace: default # namespace (CRD has scope: Namespaced)
spec:
  image: nginx:latest # container image to use (from our schema)
  banner: "This pod was created by MyApp operator 🚀"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f myapp-example-resource.yaml 
myapp.demo.rtfm.co.ua/example-app created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get myapp
NAME AGE
example-app 15s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there are no resources of type Pod  —  because we do not have a controller that will work with this type of resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a Kubernetes Operator with Kopf
&lt;/h3&gt;

&lt;p&gt;So, we will use Kopf to create a Kubernetes Pod, but using our own CRD.&lt;/p&gt;

&lt;p&gt;Create a Python virtual environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python -m venv venv 
$ . ./venv/bin/activate 
(venv)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add dependencies  — &lt;code&gt; requirements.txt&lt;/code&gt; file :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kopf 
kubernetes
PyYAML
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install them  —  with &lt;code&gt;pip&lt;/code&gt; or &lt;code&gt;uv&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ pip install -r requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s write the operator code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import os
import kopf
import kubernetes
import yaml

# use kopf to register a handler for the creation of MyApp custom resources
@kopf.on.create('demo.rtfm.co.ua', 'v1', 'myapps')
# this function will be called when a new MyApp resource is created
def create_myapp(spec, name, namespace, logger, **kwargs):
    # get image value from the spec of the CustomResource manifest
    image = spec.get('image')
    if not image:
        raise kopf.PermanentError("Field 'spec.image' must be provided.")

    # get optional banner value from the CR manifest spec
    banner = spec.get('banner')

    # load pod template YAML from file
    path = os.path.join(os.path.dirname( __file__ ), 'pod.yaml')
    with open(path, 'rt') as f:
        pod_template = f.read()

    # render pod YAML with provided values
    pod_yaml = pod_template.format(
        name=f"{name}-pod",
        image=image,
        app_name=name,
    )
    # create Pod difinition from the rendered YAML
    # it uses PyYAML to parse the YAML string into a Python dictionary
    # which can be used by Kubernetes API client
    # it is used to create a Pod object in Kubernetes
    pod_spec = yaml.safe_load(pod_yaml)

    # inject banner as environment variable if provided
    if banner:
        # it is used to add a new environment variable into the container spec
        container = pod_spec['spec']['containers'][0]
        env = container.setdefault('env', [])
        env.append({
            'name': 'BANNER',
            'value': banner
        })

    # create Kubernetes CoreV1 API client
    # used to interact with the Kubernetes API
    api = kubernetes.client.CoreV1Api()

    try:
        # it sends a request to the Kubernetes API to create a new Pod
        # uses 'create_namespaced_pod' method to create the Pod in the specified namespace
        # 'namespace' is the namespace where the Pod will be created
        # 'body' is the Pod specification that was created from the YAML template
        api.create_namespaced_pod(namespace=namespace, body=pod_spec)
        logger.info(f"Pod {name}-pod created.")
    except kubernetes.client.exceptions.ApiException as e:
        logger.error(f"Failed to create pod {name}-pod: {e}")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a template that will be used by our Operator to create resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: v1
kind: Pod
metadata:
  name: {name}
  labels:
    app: {app_name}
spec:
  containers:
    - name: {app_name}
      image: {image}
      ports:
        - containerPort: 80
      env:
        - name: BANNER
          value: "" # will be overridden in code if provided
      command: ["/bin/sh", "-c"]
      args:
        - |
          if [-n "$BANNER"]; then
            echo "$BANNER";
          fi
          exec sleep infinity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the operator with &lt;code&gt;kopf run myoperator.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We already have a CustomResource created, and the Operator should see it and create a Kubernetes Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kopf run myoperator.py --verbose
...
[2025-07-18 13:59:58,201] kopf._cogs.clients.w [DEBUG] Starting the watch-stream for customresourcedefinitions.v1.apiextensions.k8s.io cluster-wide.
[2025-07-18 13:59:58,201] kopf._cogs.clients.w [DEBUG] Starting the watch-stream for myapps.v1.demo.rtfm.co.ua cluster-wide.
[2025-07-18 13:59:58,305] kopf.objects [DEBUG] [default/example-app] Creation is in progress: {'apiVersion': 'demo.rtfm.co.ua/v1', 'kind': 'MyApp', 'metadata': {'annotations': {'kubectl.kubernetes.io/last-applied-configuration': '{"apiVersion":"demo.rtfm.co.ua/v1","kind":"MyApp","metadata":{"annotations":{},"name":"example-app","namespace":"default"},"spec":{"banner":"This pod was created by MyApp operator 🚀","image":"nginx:latest","replicas":3}}\n'}, 'creationTimestamp': '2025-07-18T09:55:42Z', 'generation': 2, 'managedFields': [{'apiVersion': 'demo.rtfm.co.ua/v1', 'fieldsType': 'FieldsV1', 'fieldsV1': {'f:metadata': {'f:annotations': {'.': {}, 'f:kubectl.kubernetes.io/last-applied-configuration': {}}}, 'f:spec': {'.': {}, 'f:banner': {}, 'f:image': {}, 'f:replicas': {}}}, 'manager': 'kubectl-client-side-apply', 'operation': 'Update', 'time': '2025-07-18T10:48:27Z'}], 'name': 'example-app', 'namespace': 'default', 'resourceVersion': '2955', 'uid': '8b674a99-05ab-4d4b-8205-725de450890a'}, 'spec': {'banner': 'This pod was created by MyApp operator 🚀', 'image': 'nginx:latest', 'replicas': 3}}
...
[2025-07-18 13:59:58,325] kopf.objects [INFO] [default/example-app] Pod example-app-pod created.
[2025-07-18 13:59:58,326] kopf.objects [INFO] [default/example-app] Handler 'create_myapp' succeeded.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pod
NAME READY STATUS RESTARTS AGE
example-app-pod 1/1 Running 0 68s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And its logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk logs -f example-app-pod 
This pod was created by MyApp operator 🚀
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So, the Operator launched the Pod using our CustomResource in which he took the &lt;code&gt;spec.banner&lt;/code&gt; field with the string &lt;em&gt;"This pod was created by MyApp operator 🚀&lt;/em&gt;", and executed the &lt;code&gt;command /bin/sh -c " $BANNER"&lt;/code&gt; command in the pod.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resource templates: Kopf and Kubebuilder
&lt;/h3&gt;

&lt;p&gt;Instead of having a separate &lt;code&gt;pod-template.yam&lt;/code&gt;l file, we could describe everything directly in the operator code.&lt;/p&gt;

&lt;p&gt;That is, you can describe something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    # get optional banner value
    banner = spec.get('banner', '')

    # define Pod spec as a Python dict
    pod_spec = {
        "apiVersion": "v1",
        "kind": "Pod",
        "metadata": {
            "name": f"{name}-pod",
            "labels": {
                "app": name,
            },
        },
        "spec": {
            "containers": [
                {
                    "name": name,
                    "image": image,
                    "env": [
                        {
                            "name": "BANNER",
                            "value": banner
                        }
                    ],
                    "command": ["/bin/sh", "-c"],
                    "args": [f'echo "$BANNER"; exec sleep infinity'],
                    "ports": [
                        {
                            "containerPort": 80
                        }
                    ]
                }
            ]
        }
    }

    # create Kubernetes API client
    api = kubernetes.client.CoreV1Api()
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the case of Kubebuilder, a function is usually created that uses the CustomResource manifest (&lt;code&gt;cr *myappv1.MyApp&lt;/code&gt;) and forms an object of type &lt;code&gt;*corev1.Pod&lt;/code&gt; using the Go structures &lt;a href="https://github.com/kubernetes/api/blob/master/core/v1/types.go#L3880" rel="noopener noreferrer"&gt;&lt;code&gt;corev1.PodSpec&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/kubernetes/api/blob/master/core/v1/types.go#L2752" rel="noopener noreferrer"&gt;&lt;code&gt;corev1.Container&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
// newPod is a helper function that builds a Kubernetes Pod object
// based on the custom MyApp resource. It returns a pointer to corev1.Pod,
// which is later passed to controller-runtime's client.Create(...) to create the Pod in the cluster.
func newPod(cr *myappv1.MyApp) *corev1.Pod {
    // `cr` is a pointer to your CustomResource of kind MyApp
    // type MyApp is generated by Kubebuilder and lives in your `api/v1/myapp_types.go`
    // it contains fields like cr.Spec.Image, cr.Spec.Banner, cr.Name, cr.Namespace, etc.
    return &amp;amp;corev1.Pod{
        // corev1.Pod is a Go struct representing the built-in Kubernetes Pod type
        // it's defined in "k8s.io/api/core/v1" package (aliased here as corev1)
        // we return a pointer to it (`*corev1.Pod`) because client-go methods like
        // `client.Create()` expect pointer types

        ObjectMeta: metav1.ObjectMeta{
            // metav1.ObjectMeta comes from "k8s.io/apimachinery/pkg/apis/meta/v1"
            // it defines metadata like name, namespace, labels, annotations, ownerRefs, etc.
            Name: cr.Name + "-pod", // generate Pod name based on the CR's name
            Namespace: cr.Namespace, // place the Pod in the same namespace as the CR
            Labels: map[string]string{ // set a label for identification or selection
                "app": cr.Name, // e.g., `app=example-app`
            },
        },

        Spec: corev1.PodSpec{
            // corev1.PodSpec defines everything about how the Pod runs
            // including containers, volumes, restart policy, etc.

            Containers: []corev1.Container{
                // define a single container inside the Pod

                {
                    Name: cr.Name, // use CR name as container name (must be DNS compliant)
                    Image: cr.Spec.Image, // container image (e.g., "nginx:1.25")

                    Env: []corev1.EnvVar{
                        // corev1.EnvVar is a struct that defines environment variables
                        {
                            Name: "BANNER", // name of the variable
                            Value: cr.Spec.Banner, // value from the CR spec
                        },
                    },

                    Command: []string{"/bin/sh", "-c"},
                    // override container ENTRYPOINT to run a shell command

                    Args: []string{
                        // run a command that prints the banner and sleeps forever
                        // fmt.Sprintf(...) injects the value at runtime into the string
                        fmt.Sprintf(`echo "%s"; exec sleep infinity`, cr.Spec.Banner),
                    },

                    // optional: could also add ports, readiness/liveness probes, etc.
                },
            },
        },
    }
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  And what about in real operators?
&lt;/h3&gt;

&lt;p&gt;But we did this for “internal” Kubernetes resources.&lt;/p&gt;

&lt;p&gt;What about external resources?&lt;/p&gt;

&lt;p&gt;Here’s just an example — I haven’t tested it, but the general idea is this: just take an SDK (in the Python example, it’s &lt;code&gt;boto3&lt;/code&gt;), and using the fields from the CustomResource (for example, &lt;code&gt;subnets&lt;/code&gt; or &lt;code&gt;schema&lt;/code&gt;), make the appropriate API requests to AWS through the SDK.&lt;/p&gt;

&lt;p&gt;An example of such a CustomResource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: demo.rtfm.co.ua/v1
kind: MyIngress
metadata:
  name: myapp
spec:
  subnets:
    - subnet-abc
    - subnet-def
  scheme: internet-facing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the code that could create an AWS ALB from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import kopf
import boto3
import botocore
import logging

# create a global boto3 client for AWS ELBv2 service
# this client will be reused for all requests from the operator
# NOTE: region must match where your subnets and VPC exist
elbv2 = boto3.client("elbv2", region_name="us-east-1")

# define a handler that is triggered when a new MyIngress resource is created
@kopf.on.create('demo.rtfm.co.ua', 'v1', 'myingresses')
def create_ingress(spec, name, namespace, status, patch, logger, **kwargs):
    # extract the list of subnet IDs from the CustomResource 'spec.subnets' field
    # these subnets must belong to the same VPC and be public if scheme=internet-facing
    subnets = spec.get('subnets')

    # extract optional scheme (default to 'internet-facing' if not provided)
    scheme = spec.get('scheme', 'internet-facing')

    # validate input: at least 2 subnets are required to create an ALB
    if not subnets:
        raise kopf.PermanentError("spec.subnets is required.")

    # attempt to create an ALB in AWS using the provided spec
    # using the boto3 ELBv2 client
    try:
        response = elbv2.create_load_balancer(
            Name=f"{name}-alb", # ALB name will be derived from CR name
            Subnets=subnets, # list of subnet IDs provided by user
            Scheme=scheme, # 'internet-facing' or 'internal'
            Type='application', # we are creating an ALB (not NLB)
            IpAddressType='ipv4', # only IPv4 supported here (could be 'dualstack')
            Tags=[ # add tags for ownership tracking
                {'Key': 'ManagedBy', 'Value': 'kopf'},
            ]
        )
    except botocore.exceptions.ClientError as e:
        # if AWS API fails (e.g. invalid subnet, quota exceeded), retry later
        raise kopf.TemporaryError(f"Failed to create ALB: {e}", delay=30)

    # parse ALB metadata from AWS response
    lb = response['LoadBalancers'][0] # ALB list should contain exactly one entry
    dns_name = lb['DNSName'] # external DNS of the ALB (e.g. abc.elb.amazonaws.com)
    arn = lb['LoadBalancerArn'] # unique ARN of the ALB (used for deletion or listeners)

    # log the creation for operator diagnostics
    logger.info(f"Created ALB: {dns_name}")

    # save ALB info into the CustomResource status field
    # this updates .status.alb.dns and .status.alb.arn in the CR object
    patch.status['alb'] = {
        'dns': dns_name,
        'arn': arn,
    }

    # return a dict, will be stored in the finalizer state
    # used later during deletion to clean up the ALB
    return {'alb-arn': arn}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the case of Go and Kubebuilder, we would use the &lt;a href="https://github.com/aws/aws-sdk-go-v2" rel="noopener noreferrer"&gt;&lt;code&gt;aws-sdk-go&lt;/code&gt;&lt;/a&gt; library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import (
    "context"
    "fmt"

    elbv2 "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2"
    "github.com/aws/aws-sdk-go-v2/aws"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    networkingv1 "k8s.io/api/networking/v1"
)

func newALB(ctx context.Context, client *elbv2.Client, cr *networkingv1.Ingress) (string, error) {
    // build input for the ALB
    input := &amp;amp;elbv2.CreateLoadBalancerInput{
        Name: aws.String(fmt.Sprintf("%s-alb", cr.Name)),
        Subnets: []string{"subnet-abc123", "subnet-def456"}, // replace with real subnets
        Scheme: elbv2.LoadBalancerSchemeEnumInternetFacing,
        Type: elbv2.LoadBalancerTypeEnumApplication,
        IpAddressType: elbv2.IpAddressTypeIpv4,
        Tags: []types.Tag{
            {
                Key: aws.String("ManagedBy"),
                Value: aws.String("MyIngressOperator"),
            },
        },
    }

    // create ALB
    output, err := client.CreateLoadBalancer(ctx, input)
    if err != nil {
        return "", fmt.Errorf("failed to create ALB: %w", err)
    }

    if len(output.LoadBalancers) == 0 {
        return "", fmt.Errorf("ALB was not returned by AWS")
    }

    // return the DNS name of the ALB
    return aws.ToString(output.LoadBalancers[0].DNSName), nil
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the real &lt;a href="https://kubernetes-sigs.github.io/aws-load-balancer-controller/v1.1/" rel="noopener noreferrer"&gt;AWS ALB Ingress Controller&lt;/a&gt;, the creation of an ALB is called in the &lt;a href="https://github.com/kubernetes-sigs/aws-load-balancer-controller/blob/3241ca92eecaf0167a2ad9edad0ba09f9091ba73/pkg/aws/services/elbv2.go#L290" rel="noopener noreferrer"&gt;&lt;code&gt;elbv2.go&lt;/code&gt;&lt;/a&gt; file :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
func (c *elbv2Client) CreateLoadBalancerWithContext(ctx context.Context, input *elasticloadbalancingv2.CreateLoadBalancerInput) (*elasticloadbalancingv2.CreateLoadBalancerOutput, error) {
  client, err := c.getClient(ctx, "CreateLoadBalancer")
  if err != nil {
    return nil, err
  }
  return client.CreateLoadBalancer(ctx, input)
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actually, that’s all there is to it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/kubernetes-what-are-the-kubernetes-operator-and-customresourcedefinition/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>todayilearned</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>AWS: creating an OpenSearch Service cluster and configuring authentication and authorization</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Tue, 16 Sep 2025 06:55:43 +0000</pubDate>
      <link>https://forem.com/aws-heroes/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization-5aih</link>
      <guid>https://forem.com/aws-heroes/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization-5aih</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" width="640" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the previous part, &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;AWS: Getting Started with OpenSearch Service as a Vector Store&lt;/a&gt;, we looked at AWS OpenSearch Service in general, figured out how data is organized in it, what shards and nodes are, and what types of instances we actually need for data nodes.&lt;/p&gt;

&lt;p&gt;The next step is to create a cluster and look at authentication, which, in my opinion, is even more complicated than AWS EKS. Although, maybe it’s just a matter of habit.&lt;/p&gt;

&lt;p&gt;What we’re going to do today is manually create an AWS OpenSearch Service cluster, look at the main options for creating a cluster, and then dive into the settings for accessing the cluster and OpenSearch Dashboards with AWS IAM and Fine-grained access control of OpenSearch itself and its Security plugin.&lt;/p&gt;

&lt;p&gt;And in the next part, if I have time to write it, we’ll get to Terraform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Manually creating a cluster in AWS Console&lt;/li&gt;
&lt;li&gt;Storage&lt;/li&gt;
&lt;li&gt;Nodes&lt;/li&gt;
&lt;li&gt;Network&lt;/li&gt;
&lt;li&gt;Access &amp;amp;&amp;amp; permissions&lt;/li&gt;
&lt;li&gt;Authentication and authorization&lt;/li&gt;
&lt;li&gt;Configuring Domain Access policy&lt;/li&gt;
&lt;li&gt;Resource-based policy&lt;/li&gt;
&lt;li&gt;IP-based policies and access to the OpenSearch Dashboards&lt;/li&gt;
&lt;li&gt;Identity-based policy&lt;/li&gt;
&lt;li&gt;Fine-grained access control&lt;/li&gt;
&lt;li&gt;Configuring the Fine-grained access control&lt;/li&gt;
&lt;li&gt;Creating an OpenSearch Role&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Manually creating a cluster in AWS Console
&lt;/h3&gt;

&lt;p&gt;We will do a minimal PoC to play around, i.e., with t3 instances in one Availability Zone and without Master Nodes.&lt;/p&gt;

&lt;p&gt;In Production, we also plan to have one small cluster with three dev/staging/prod indexes as a vector store for AWS Bedrock Knowledge Base.&lt;/p&gt;

&lt;p&gt;Documentation from AWS — &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/createupdatedomains.html#createdomains" rel="noopener noreferrer"&gt;Creating OpenSearch Service domains&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Go to Amazon OpenSearch Service &amp;gt; Domains, click “Create domain”.&lt;/p&gt;

&lt;p&gt;Set a name, select “Standard create” to have access to all options:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwinj22oxyuf3m8670dxe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwinj22oxyuf3m8670dxe.png" width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In “Templates”, select “Dev/test” — then you can choose a configuration without Master Nodes and deploy in a single Availability Zone.&lt;/p&gt;

&lt;p&gt;In “Deployment option(s)”, select “Domain without standby”  —  then you will have access to &lt;code&gt;t3&lt;/code&gt; instances:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feav5m11apr0ztcdm1o50.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feav5m11apr0ztcdm1o50.png" width="800" height="701"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The case conveniently shows us the entire setup right away.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage
&lt;/h3&gt;

&lt;p&gt;We discussed the number of shards per cluster in the previous post. Let’s assume that we plan to have a maximum of 20–30 GiB of data, so we will create 1 primary shard and 1 replica. But the shards will be configured later, when we create indexes with Terraform and &lt;a href="https://registry.terraform.io/providers/opensearch-project/opensearch/latest/docs/resources/index_template" rel="noopener noreferrer"&gt;&lt;code&gt;opensearch_index_template&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And for these two shards, we will create two Data Nodes — one for the primary shard and one for the replica.&lt;/p&gt;

&lt;p&gt;“Engine options” are described in &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/features-by-version.html" rel="noopener noreferrer"&gt;Features by engine version in Amazon OpenSearch Service&lt;/a&gt;. Just leave the default value, the latest version.&lt;/p&gt;

&lt;p&gt;For “Instance family” select “General purpose”, and for “Instance type,” select &lt;code&gt;t3.small.search&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For the “EBS storage size per node” we will take 50 GiB — 20–30 gigabytes for data and a little extra for the operating system itself:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxirojee502h2ql37orea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxirojee502h2ql37orea.png" width="800" height="742"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Nodes
&lt;/h3&gt;

&lt;p&gt;Leave “Number of master nodes” and “Dedicated coordinator nodes” unchanged, i.e. without them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw9v14vuasm3qn62mkdxn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fw9v14vuasm3qn62mkdxn.png" width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Network
&lt;/h3&gt;

&lt;p&gt;We are not changing anything in “Custom endpoint” yet, but later you can add your own domain from Route53 with a certificate from AWS Certificate Manager to access the cluster, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/customendpoint.html" rel="noopener noreferrer"&gt;Creating a custom endpoint for Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the “Network”, we are going with the simplest option for now, “Public access”, but for Production, we will do it inside the VPC:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs7xlhoammsl999omwp9v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fs7xlhoammsl999omwp9v.png" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;However, you will need to test access to Dashboards, because if the cluster is created in VPC subnets, IP-based policies cannot be applied to it, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html#vpc-security" rel="noopener noreferrer"&gt;About access policies on VPC domains&lt;/a&gt;. We will discuss IP-based policies further here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access &amp;amp;&amp;amp; permissions
&lt;/h3&gt;

&lt;p&gt;Fine-grained access control (FGAC) — we’ll disable it for now and take a closer look at this mechanism later. Although I’m not sure it will be necessary, because you can easily divide access to different indexes in a single cluster using IAM.&lt;/p&gt;

&lt;p&gt;SAML, JWT, and IAM Identity Center depend on FGAC, so we’ll skip them too, and I don’t plan to use them in the future, as they are not relevant to our case.&lt;/p&gt;

&lt;p&gt;Cognito is also out of the question — we don’t use it (although later, I may look into integrating with Auth0 or Cognito for Dashboards):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F804%2F0%2AhbO7zW7mPNoman55.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F804%2F0%2AhbO7zW7mPNoman55.png" width="800" height="955"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;“Access policy”” can be compared to S3 Access Policy, or to IAM Policy for EKS, which allows IAM users to access the cluster.&lt;/p&gt;

&lt;p&gt;We will discuss this in more detail in the section on authentication. For now, let’s just leave the default - "Do not set domain level access policy” option selected:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F774%2F0%2AmpgWqGKZT3lWsdrf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F774%2F0%2AmpgWqGKZT3lWsdrf.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The “Off-peak window” is the time of lowest load for installing updates and performing Auto-tune operations.&lt;/p&gt;

&lt;p&gt;Our off-peak time will be at night in the US, so Production will be Central Time (CT) 05:00 UTC.&lt;/p&gt;

&lt;p&gt;But since this is a test PoC, we’ll skip that too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/auto-tune.html" rel="noopener noreferrer"&gt;Auto-Tune&lt;/a&gt; is also well described and unavailable for our &lt;code&gt;t3&lt;/code&gt; instances.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/service-software.html" rel="noopener noreferrer"&gt;Automatic software update&lt;/a&gt; is a useful feature for Production and will be performed at the time specified in the Off-peak window:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F567%2F0%2A9sGqBzgPWWjyMdCc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F567%2F0%2A9sGqBzgPWWjyMdCc.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In еру “Advanced cluster settings” you can disable &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/APIReference/API_AdvancedOptionsStatus.html" rel="noopener noreferrer"&gt;&lt;code&gt;rest.action.multi.allow_explicit_index&lt;/code&gt;&lt;/a&gt;, but I don't know how our queries will be built, and I think I read somewhere that it can break the Dashboard, so let's leave the default &lt;em&gt;enabled&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F679%2F0%2AvQLR88Gad9BnqDj4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F679%2F0%2AvQLR88Gad9BnqDj4.png" width="679" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And that’s it, as a result we have the following setup:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2Af2mecNhF7oH29PWT.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F319%2F0%2Af2mecNhF7oH29PWT.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click “Create” and go have some tea, because creating a cluster takes a long time — longer than EKS, and creating OpenSearch took about 20 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication and authorization
&lt;/h3&gt;

&lt;p&gt;Now, perhaps, the most interesting part — users and access.&lt;/p&gt;

&lt;p&gt;After creating a cluster, by default we have limited access rights to the OpenSearch API itself:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AdiUVzukwjEOm9Alt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AdiUVzukwjEOm9Alt.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because in the “Security Configuration” we have an explicit Deny:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F841%2F0%2Ak6vHHTj76j_2c3oD.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F841%2F0%2Ak6vHHTj76j_2c3oD.png" width="800" height="674"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Access to AWS OpenSearch Service has three “levels” — network, IAM, and OpenSearch’s own Security Plugin.&lt;/p&gt;

&lt;p&gt;In IAM, we have two entities —  &lt;strong&gt;Domain Access Policy&lt;/strong&gt; , which we see in Security Configuration &amp;gt; Access Policy (attribute &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain#access_policies-1" rel="noopener noreferrer"&gt;&lt;code&gt;access_policies&lt;/code&gt;&lt;/a&gt; in Terraform), and &lt;strong&gt;Identity-based policies&lt;/strong&gt; - which are regular AWS IAM Policies.&lt;/p&gt;

&lt;p&gt;If we talk about these levels in more detail, they look something like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Network: Network &amp;gt; VPC access or Public access parameter: we set the access limit at the network level (see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html" rel="noopener noreferrer"&gt;Launching your Amazon OpenSearch Service domains within a VPC&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;or, if we take an analogy with EKS, these are Public and Private API endpoints, or with RDS, creating an instance in public or private subnets&lt;/li&gt;
&lt;li&gt;AWS IAM:&lt;/li&gt;
&lt;li&gt;Domain Access Policies:&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html#ac-types-resource" rel="noopener noreferrer"&gt;Resource-based policies&lt;/a&gt;: policies that are described directly in the cluster settings&lt;/li&gt;
&lt;li&gt;access is set for IAM Role, IAM User, AWS Accounts to a specific OpenSearch domain&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html#ac-types-ip" rel="noopener noreferrer"&gt;IP-based policies&lt;/a&gt;: essentially the same as Resource-based policies, but with the ability to allow access without authentication for specific IPs (only if the access type is Public, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/vpc.html#vpc-security" rel="noopener noreferrer"&gt;VPC versus public domains&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html#ac-types-identity" rel="noopener noreferrer"&gt;Identity-based policies&lt;/a&gt;: if Resource-based policies are part of the cluster’s security policy settings, then Identity-based policies are regular AWS IAM Policies that are added to a specific user or role&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html" rel="noopener noreferrer"&gt;Fine-grained access control&lt;/a&gt; (FGAC): OpenSearch’s own Security Plugin  —  the &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain#advanced_security_options-1" rel="noopener noreferrer"&gt;&lt;code&gt;advanced_security_options&lt;/code&gt;&lt;/a&gt; attribute in Terraform&lt;/li&gt;
&lt;li&gt;if in Resource-based policies and Identity-based policies we set rules at the cluster (domain) and index levels, then in FGAC we can additionally describe restrictions on specific documents or fields&lt;/li&gt;
&lt;li&gt;and even if Resource-based policies and Identity-based policies allow access to a resource in the cluster, it can be “trimmed” through Fine-grained access control&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is, the authentication and authorization flow will be as follows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AWS API receives a request from the user, for example &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/APIReference/Welcome.html" rel="noopener noreferrer"&gt;&lt;code&gt;es:ESHttpGet&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS IAM performs authentication — checks ACCESS:SECRET keys or Session token&lt;/li&gt;
&lt;li&gt;AWS IAM performs authorization:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;checks the user’s IAM Policy ( &lt;strong&gt;Identity-based policy&lt;/strong&gt; ), if there is explicit permission here — we skip&lt;/li&gt;
&lt;li&gt;checks the Domain Access Policy ( &lt;strong&gt;Resource-based policy&lt;/strong&gt; ) of the cluster, if there is explicit permission here — we skip&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;The request comes to OpenSearch itself&lt;/li&gt;
&lt;li&gt;If Fine-grained access control is not enabled, we allow it&lt;/li&gt;
&lt;li&gt;If Fine-grained access control is configured, we check internal roles, and if the user is allowed, we execute the request&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let’s make some accesses and see how it all works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring Domain Access policy
&lt;/h3&gt;

&lt;p&gt;The basic option is to add IAM User access to the cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resource-based policy
&lt;/h3&gt;

&lt;p&gt;Edit the “Access policy” and specify your user, API operation types, and domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:user/arseny.zinchenko"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F639%2F0%2AlEFLvcRQNeVBOYjR.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F639%2F0%2AlEFLvcRQNeVBOYjR.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Wait a minute, and now we have access to the OpenSearch API (because Cluster health in the AWS Console is obtained from OpenSearch — see &lt;a href="https://docs.opensearch.org/latest/api-reference/cluster-api/cluster-health/" rel="noopener noreferrer"&gt;Cluster Health API&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AOr6UlHivW2wHAB3F.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AOr6UlHivW2wHAB3F.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And now we can use &lt;code&gt;curl&lt;/code&gt; and &lt;a href="https://curl.se/docs/manpage.html" rel="noopener noreferrer"&gt;&lt;code&gt;--aws-sigv4&lt;/code&gt;&lt;/a&gt; to access the cluster (see &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html" rel="noopener noreferrer"&gt;Authenticating Requests (AWS Signature Version 4)&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" \
&amp;gt; --user "AKI ***B7A:pAu*** 2gW" \
&amp;gt; https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
  "number_of_data_nodes" : 2,
  "discovered_master" : true,
  "discovered_cluster_manager" : true,
  "active_primary_shards" : 5,
  "active_shards" : 10,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  IP-based policies and access to the OpenSearch Dashboards
&lt;/h3&gt;

&lt;p&gt;Similarly, through Domain Access Policy, we can open access to Dashboards — the simplest option, but it only works with Public domains. If the cluster is in VPC, additional authentication will be required, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/dashboards.html#dashboards-access" rel="noopener noreferrer"&gt;Controlling access to Dashboards&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Edit the policy, add condition &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_deny-ip.html" rel="noopener noreferrer"&gt;&lt;code&gt;IpAddress.aws:SourceIp&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::492***148:user/arseny.zinchenko"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
    },
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": "es:ESHttp*",
      "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*",
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": "178. ***.***.184"
        }
      }
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now we have access to the Dashboards:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2Ai9atXE7MAu3yPeeA.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2Ai9atXE7MAu3yPeeA.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Identity-based policy
&lt;/h3&gt;

&lt;p&gt;Now, the second option is to create a separate IAM User and connect a separate IAM Policy to it.&lt;/p&gt;

&lt;p&gt;Add a user in AWS IAM:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AJCWVpo0xSuWcUtuY.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AJCWVpo0xSuWcUtuY.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We can just take a ready-made &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac-managed.html" rel="noopener noreferrer"&gt;AWS managed policies for Amazon OpenSearch Service&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A-HyXuvJI4Pc8lXy5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A-HyXuvJI4Pc8lXy5.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, we simply create access keys for the Command Line Interface (CLI) and, without changing anything in the cluster’s Access policy, check access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI ***YUK:fXV*** 34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
  "number_of_data_nodes" : 2,
  "discovered_master" : true,
  "discovered_cluster_manager" : true,
  "active_primary_shards" : 5,
  "active_shards" : 10,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0,
  "delayed_unassigned_shards" : 0,
  "number_of_pending_tasks" : 0,
  "number_of_in_flight_fetch" : 0,
  "task_max_waiting_in_queue_millis" : 0,
  "active_shards_percent_as_number" : 100.0
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So now we have a Domain Access Policy that grants access specifically to my user, and there is a separate IAM Policy — an identity-based policy — that grants access to the test user.&lt;/p&gt;

&lt;p&gt;But there is one important point here: in the IAM Policy, we specify either the entire domain or only its subresources.&lt;/p&gt;

&lt;p&gt;That is, if instead of the &lt;code&gt;AmazonOpenSearchServiceFullAccess&lt;/code&gt; policy, we create our own policy in which we specify &lt;code&gt;"Resource":***:domain/test/*"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "es:*"
            ],
            "Resource": "arn:aws:es:us-east-1:492***148:domain/test/*"
        }
    ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So we can execute &lt;code&gt;es:ESHttpGet&lt;/code&gt; (&lt;code&gt;GET _cluster/health&lt;/code&gt;) - but we cannot execute cluster-level operations, such as &lt;code&gt;es:AddTags&lt;/code&gt;, even though we have permission for all calls in the &lt;code&gt;Actions&lt;/code&gt; of the IAM policy - &lt;code&gt;es:*&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ aws --profile test-os opensearch add-tags --arn arn:aws:es:us-east-1:492***148:domain/test --tag-list Key=environment,Value=test

An error occurred (AccessDeniedException) when calling the AddTags operation: User: arn:aws:iam::492 ***148:user/test-opesearch-identity-based-policy is not authorized to perform: es:AddTags on resource: arn:aws:es:us-east-1:492*** 148:domain/test because no identity-based policy allows the es:AddTags action
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we want to allow all operations with the cluster, we need to set &lt;code&gt;"Resource"&lt;/code&gt; to &lt;code&gt;"arn:aws:es:us-east-1:492***148:domain/test"&lt;/code&gt;, and then we can add tags.&lt;/p&gt;

&lt;p&gt;See all API actions in &lt;a href="https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonopensearchservice.html" rel="noopener noreferrer"&gt;Actions, resources, and condition keys for Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fine-grained access control
&lt;/h3&gt;

&lt;p&gt;Documentation — &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html" rel="noopener noreferrer"&gt;Fine-grained access control in Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The basic idea is very similar to Kubernetes RBAC.&lt;/p&gt;

&lt;p&gt;In OpenSearch, there are three main concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;users&lt;/strong&gt;  — like Kubernetes Users and ServiceAccounts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;roles&lt;/strong&gt;  — like Kubernetes RBAC Roles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mappings&lt;/strong&gt;  — like Kubernetes Role Bindings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Users can be from both AWS IAM and the internal OpenSearch database.&lt;/p&gt;

&lt;p&gt;As in Kubernetes, OpenSearch has a set of default roles — see &lt;a href="https://docs.opensearch.org/1.0/security-plugin/access-control/users-roles/#predefined-roles" rel="noopener noreferrer"&gt;Predefined roles&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;At the same time, roles, as in Kubernetes, can be cluster-wide or index-specific — analogous to ClusterRoleBinding and simply namespaced RoleBinding in Kubernetes, plus in OpenSearch FGAC you can additionally have document level or field level permissions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuring the Fine-grained access control
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Important note&lt;/strong&gt; : once FGAC is enabled, you will not be able to revert to the old scheme. However, all accesses from IAM will remain, even if you switch to the internal database.&lt;/p&gt;

&lt;p&gt;Edit “Security configuration” and enable “Fine-grained access control”:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ACrUCeN88_UIAzLAc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ACrUCeN88_UIAzLAc.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;First, we need to set up a Master user, which can be specified from IAM or created locally in OpenSearch.&lt;/p&gt;

&lt;p&gt;If we create a user via the “Create master user” option, we specify a regular login:password, and in this case, OpenSearch will connect to the internal user database (&lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/opensearch_domain#internal_user_database_enabled-1" rel="noopener noreferrer"&gt;&lt;code&gt;internal_user_database_enabled&lt;/code&gt;&lt;/a&gt; in Terraform).&lt;/p&gt;

&lt;p&gt;If we use the internal OpenSearch database, we can have regular users and perform HTTP basic authentication. See the AWS documentation — &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac-http-auth.html" rel="noopener noreferrer"&gt;Tutorial: Configure a domain with the internal user database and HTTP basic authentication&lt;/a&gt; and &lt;a href="https://docs.opensearch.org/latest/security/access-control/users-roles/" rel="noopener noreferrer"&gt;Defining users and roles&lt;/a&gt; in the OpenSearch documentation itself, as these are its internal mechanisms.&lt;/p&gt;

&lt;p&gt;This makes sense if you don’t want to use Cognito or SAML, and if each cluster will have its own user settings.&lt;/p&gt;

&lt;p&gt;If you set an IAM user, the scheme will be similar to AIM authentication for RDS and IAM database authentication — access to the cluster is controlled by AWS IAM, but internal access to schemas and databases is controlled by PostgreSQL or MariaDB roles, see &lt;a href="https://rtfm.co.ua/aws-rds-z-iam-database-authentication-eks-pod-identities-ta-terraform/" rel="noopener noreferrer"&gt;AWS: RDS with IAM database authentication, EKS Pod Identities, and Terraform&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In this case, AWS IAM will only perform user authentication, while authorization (access rights verification) will be handled by the Security plugin and OpenSearch roles.&lt;/p&gt;

&lt;p&gt;Let’s try a local database, and I think we’ll use this scheme in Production as well:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F820%2F0%2AvA5RDXZGFalLk_NY.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F820%2F0%2AvA5RDXZGFalLk_NY.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We can leave “Access Policy” as it is:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F820%2F0%2AtQPRfTfRaSEFKC3s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F820%2F0%2AtQPRfTfRaSEFKC3s.png" width="800" height="813"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Switching to the internal database will take some time because it will trigger a blue/green deployment of the new cluster, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-configuration-changes.html" rel="noopener noreferrer"&gt;Making configuration changes in Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And it took a long time, &lt;strong&gt;more than an hour&lt;/strong&gt; , even though there is no data of ours in the cluster.&lt;/p&gt;

&lt;p&gt;Once the changes are applied, Dashboards will now ask for a login and password. Use our Master user:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F380%2F0%2AZ86bTUcjCpD4QOfG.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F380%2F0%2AZ86bTUcjCpD4QOfG.png" width="380" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The master user receives two connected roles: &lt;code&gt;all_access&lt;/code&gt; and &lt;code&gt;security_manager&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is &lt;code&gt;security_manager&lt;/code&gt; that provides access to the Security and Users sections in the dashboard:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F822%2F0%2AMWquXL1t7t1nvuon.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F822%2F0%2AMWquXL1t7t1nvuon.png" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ADKi2Y9yfb_hL_8Sc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2ADKi2Y9yfb_hL_8Sc.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the same time, we still have access to our AIM users, and we can continue to use curl: IAM users will be mapped to the &lt;code&gt;default_role&lt;/code&gt;, which allows GET/PUT on all indexes - see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/fgac.html" rel="noopener noreferrer"&gt;About the default_role&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AQTycy0HCfNYZgwnB.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AQTycy0HCfNYZgwnB.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s check our test user’s access now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI ***YUK:fXV*** 34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "cluster_name" : "492***148:test",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 2,
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let’s cut off access to all IAM users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating an OpenSearch Role
&lt;/h3&gt;

&lt;p&gt;To see how it works, let’s add a test index and map our test user with access to this index.&lt;/p&gt;

&lt;p&gt;Add the index:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APQUDJumbZSXrBFmU.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APQUDJumbZSXrBFmU.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F866%2F0%2A60U1pDJYwQYxHBvV.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F866%2F0%2A60U1pDJYwQYxHBvV.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Go to Security &amp;gt; Roles, add a role:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AMLrRYcAFOBA8vmZl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AMLrRYcAFOBA8vmZl.png" width="800" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Set Index permissions — full access to the index (&lt;code&gt;crud&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AhToY-wA5E8fBqeLf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AhToY-wA5E8fBqeLf.png" width="800" height="773"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Next, in this role, we move on to Mapped users &amp;gt; Map users:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APECCieUOba6ggmoq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2APECCieUOba6ggmoq.png" width="800" height="314"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And add the ARN of our test user:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F924%2F0%2AwnJsiGZqHWh-TdxF.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F924%2F0%2AwnJsiGZqHWh-TdxF.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Delete the default role:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A6dhZJhZTvhPEGaE9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A6dhZJhZTvhPEGaE9.png" width="800" height="189"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now our user does not have access to &lt;code&gt;GET _cluster/health&lt;/code&gt; - here we get an error &lt;strong&gt;403, no permissions&lt;/strong&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI ***YUK:fXV*** 34I" https://search-test-***.us-east-1.es.amazonaws.com/_cluster/health?pretty
{
  "error" : {
    ...
    "type" : "security_exception",
    "reason" : "no permissions for [cluster:monitor/health] and User [name=arn:aws:iam::492***148:user/test-opesearch-identity-based-policy, backend_roles=[], requestedTenant=null]"
  },
  "status" : 403
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But has access to the test index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl --aws-sigv4 "aws:amz:us-east-1:es" --user "AKI ***YUK:fXV*** 34I" https://search-test-***.us-east-1.es.amazonaws.com/test-allowed-index/_search?pretty -d '{
    "query": {
      "match_all": {}
    }
  }' -H 'Content-Type: application/json'
{
  "took" : 78,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : []
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>aws</category>
      <category>security</category>
    </item>
    <item>
      <title>Kubernetes: Kubernetes API, API groups, CRDs, and the etcd</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Mon, 15 Sep 2025 22:41:13 +0000</pubDate>
      <link>https://forem.com/setevoy/kubernetes-kubernetes-api-api-groups-crds-and-the-etcd-1can</link>
      <guid>https://forem.com/setevoy/kubernetes-kubernetes-api-api-groups-crds-and-the-etcd-1can</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I actually started to write about creating my own Kubernetes Operator, but decided to make a separate topic about what a Kubernetes CustomResourceDefinition is, and how creating a CRD works at the level of the Kubernetes API and the &lt;code&gt;etcd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is, to start with how Kubernetes actually works with resources, and what happens when we create or edit resources.&lt;/p&gt;

&lt;p&gt;The second part: &lt;a href="https://rtfm.co.ua/kubernetes-shho-take-kubernetes-operator-ta-customresourcedefinition/" rel="noopener noreferrer"&gt;Kubernetes: what is Kubernetes Operator and CustomResourceDefinition&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes API&lt;/li&gt;
&lt;li&gt;Kubernetes API Groups and Kind&lt;/li&gt;
&lt;li&gt;Kubernetes and etcd&lt;/li&gt;
&lt;li&gt;CustomResourceDefinitions and Kubernetes API&lt;/li&gt;
&lt;li&gt;Kubernetes API Service&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kubernetes API
&lt;/h3&gt;

&lt;p&gt;So, all communication with the Kubernetes Control Plane takes place through its main endpoint — the Kubernetes API, which is a component of the Kubernetes Control Plane — see &lt;a href="https://kubernetes.io/docs/concepts/architecture/" rel="noopener noreferrer"&gt;Cluster Architecture&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Documentation — &lt;a href="https://kubernetes.io/docs/concepts/overview/kubernetes-api/" rel="noopener noreferrer"&gt;The Kubernetes API&lt;/a&gt; and &lt;a href="https://kubernetes.io/docs/reference/using-api/api-concepts/" rel="noopener noreferrer"&gt;Kubernetes API Concepts&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Through the API, we communicate with Kubernetes, and all resources and information about them are stored in the database  — &lt;code&gt; etcd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Other components of the Control Plane are the &lt;a href="https://medium.com/@nuwanwe/understanding-the-kubernetes-controller-manager-b34db5b92dd4" rel="noopener noreferrer"&gt;Kube Controller Manager&lt;/a&gt; with a set of default controllers that are responsible for working with resources, and the Scheduler, which is responsible for how resources will be placed on Worker Nodes.&lt;/p&gt;

&lt;p&gt;The Kubernetes API is just a regular HTTPS REST API that we can access even from &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To access the cluster, we can use &lt;code&gt;kubectl proxy&lt;/code&gt;, which will take the parameters from &lt;code&gt;~/.kube/config&lt;/code&gt; with the API Server address and token, and create a tunnel to it.&lt;/p&gt;

&lt;p&gt;I have access to AWS EKS configured, so the connection will go to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl proxy --port=8080 
Starting to serve on 127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And we turn to the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:8080 | jq
{
  "paths": [
    "/.well-known/openid-configuration",
    "/api",
    "/api/v1",
    "/apis",
    ...
    "/version"
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actually, what we see is a list of API endpoints supported by the Kubernetes API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/api/&lt;/code&gt;: information on the Kubernetes API itself and the entry point to the core API Groups (see below)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/v1&lt;/code&gt;: core API group with Pods, ConfigMaps, Services, etc.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/apis/&lt;/code&gt;: APIGroupList - the rest of the API Groups in the system and their versions, including API Groups created from different CRDs&lt;/li&gt;
&lt;li&gt;for example, for the API Group &lt;code&gt;operator.victoriametrics.com&lt;/code&gt; we can see support for two versions - "&lt;code&gt;operator.victoriametrics.com/v1&lt;/code&gt;" "&lt;code&gt;operator.victoriametrics.com/v1beta1&lt;/code&gt;"&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/version&lt;/code&gt;: information on the cluster version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then we can go deeper and see what’s inside each endpoint, for example, to get information about all Pods in the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:8080/api/v1/pods | jq
...
    {
      "metadata": {
        "name": "backend-ws-deployment-6db58cc97c-k56lm",
      ...
        "namespace": "staging-backend-api-ns"
        "labels": {
          "app": "backend-ws",
          "component": "backend",
      ...
      "spec": {
        "volumes": [
          {
            "name": "eks-pod-identity-token",
      ...
        "containers": [
          {
            "name": "backend-ws-container",
            "image": "492***148.dkr.ecr.us-east-1.amazonaws.com/challenge-backend-api:v0.171.9",
            "command": [
              "gunicorn",
              "websockets_backend.run_api:app",
      ...
            "resources": {
              "requests": {
                "cpu": "200m",
                "memory": "512Mi"
              }
            },
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we can see information about the Pod named &lt;em&gt;“backend-ws-deployment-6db58cc97c-k56lm&lt;/em&gt;” which lives in the Kubernetes Namespace “&lt;em&gt;staging-backend-api-ns&lt;/em&gt;”, and the rest of the information about it — the volumes, which containers, resources, etc.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes API Groups and Kind
&lt;/h3&gt;

&lt;p&gt;API Groups are a way to organize resources in Kubernetes. They are grouped by groups, versions, and resource types (Kind).&lt;/p&gt;

&lt;p&gt;That is the structure of the API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API Group&lt;/li&gt;
&lt;li&gt;versions&lt;/li&gt;
&lt;li&gt;kind&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, in &lt;code&gt;/api/v1&lt;/code&gt; we see the Kubernetes Core API Group, in &lt;code&gt;/apis&lt;/code&gt; - API Groups &lt;code&gt;apps&lt;/code&gt;, &lt;code&gt;batch&lt;/code&gt;, &lt;code&gt;events&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;The structure will be as follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/apis/&amp;lt;group&amp;gt;&lt;/code&gt; - the group itself and its versions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/apis/&amp;lt;group&amp;gt;/&amp;lt;version&amp;gt;&lt;/code&gt; - a specific version of the group with specific resources (Kind)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/apis/&amp;lt;group&amp;gt;/&amp;lt;version&amp;gt;/&amp;lt;resource&amp;gt;&lt;/code&gt; - access to a specific resource and objects in it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;: Kind vs resource: Kind is the name of the resource that is specified in the schema of this resource. And resource is the name that is used to build the URI when requesting the API Server.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For example, for the API Group &lt;code&gt;apps&lt;/code&gt; we have the version &lt;code&gt;v1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:8080/apis/apps | jq
{
  "kind": "APIGroup",
  "apiVersion": "v1",
  "name": "apps",
  "versions": [
    {
      "groupVersion": "apps/v1",
      "version": "v1"
    }
  ],
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And inside the version   — resources, for example, &lt;code&gt;deployments&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:8080/apis/apps/v1 | jq
{
...
    {
      "name": "deployments",
      "singularName": "deployment",
      "namespaced": true,
      "kind": "Deployment",
      "verbs": [
        "create",
        "delete",
        "deletecollection",
        "get",
        "list",
        "patch",
        "update",
        "watch"
      ],
      "shortNames": [
        "deploy"
      ],
      "categories": [
        "all"
      ],
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And using this group, version, and specific resource type (kind), we get all the objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:8080/apis/apps/v1/deployments/ | jq
{
  "kind": "DeploymentList",
  "apiVersion": "apps/v1",
  "metadata": {
    "resourceVersion": "1534"
  },
  "items": [
    {
      "metadata": {
        "name": "coredns",
        "namespace": "kube-system",
        "uid": "9d7f6de3-041e-4afe-84f4-e124d2cc6e8a",
        "resourceVersion": "709",
        "generation": 2,
        "creationTimestamp": "2025-07-12T10:15:33Z",
        "labels": {
          "k8s-app": "kube-dns"
        },
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Okay, so we’ve accessed the API — but where does it get all that data that we’re being shown?&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes and &lt;code&gt;etcd&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;For storing data in Kubernetes, we have another key component of the Control Plane  —  &lt;a href="https://etcd.io/" rel="noopener noreferrer"&gt;&lt;code&gt;etcd&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Actually, this is just a key:value database with all the data that forms our cluster — all its settings, all resources, all states of these resources, RBAC rules, etc.&lt;/p&gt;

&lt;p&gt;When the Kubernetes API Server receives a request, for example, &lt;code&gt;POST /apis/apps/v1/namespaces/default/deployments&lt;/code&gt;, it first checks if the object matches the resource schema (validation), and only then saves it to &lt;code&gt;etcd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;etcd&lt;/code&gt; database consists of a set of keys. For example, a Pod named &lt;em&gt;"nginx-abc&lt;/em&gt;" will be stored in a key named &lt;code&gt;/registry/pods/default/nginx-abc&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;See the documentation &lt;a href="https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/" rel="noopener noreferrer"&gt;Operating etcd clusters for Kubernetes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In AWS EKS, we don’t have access to &lt;code&gt;etcd&lt;/code&gt; (and that's a good thing), but we can start Minikube and have a look at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ minikube start
...
🏄 Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the system pods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl -n kube-system get pod
NAME READY STATUS RESTARTS AGE
coredns-674b8bbfcf-68q8p 0/1 ContainerCreating 0 57s
etcd-minikube 1/1 Running 0 62s
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect to the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ minikube ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we had used &lt;code&gt;minikube start --driver=virtualbox&lt;/code&gt;, we would have used &lt;code&gt;minikube ssh&lt;/code&gt; to enter the VirtualBox instance.&lt;/p&gt;

&lt;p&gt;But since we have the default &lt;code&gt;docker&lt;/code&gt; driver, we simply enter the &lt;code&gt;minikube&lt;/code&gt; container.&lt;/p&gt;

&lt;p&gt;Install &lt;code&gt;etcd&lt;/code&gt; here to get the &lt;code&gt;etcdctl&lt;/code&gt; CLI utility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker@minikube:~$ sudo apt update 
docker@minikube:~$ sudo apt install etcd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker@minikube:~$ etcdctl -version 
etcdctl version: 3.3.25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now we can see what’s in the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker@minikube:~$ sudo ETCDCTL_API=3 etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/var/lib/minikube/certs/etcd/ca.crt \
  --cert=/var/lib/minikube/certs/etcd/server.crt \
  --key=/var/lib/minikube/certs/etcd/server.key \
  get "" --prefix --keys-only
...
/registry/namespaces/kube-system
/registry/pods/kube-system/coredns-674b8bbfcf-68q8p
/registry/pods/kube-system/etcd-minikube
...
/registry/services/endpoints/default/kubernetes
/registry/services/endpoints/kube-system/kube-dns
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data in the keys is stored in Protobuf &lt;a href="https://github.com/protocolbuffers/protobuf" rel="noopener noreferrer"&gt;(Protocol Buffers&lt;/a&gt;) format, so with the usual &lt;code&gt;etcdctl get KEY&lt;/code&gt;, the data will look a little crooked.&lt;/p&gt;

&lt;p&gt;Let’s see what is in the database about the Pod of &lt;code&gt;etcd&lt;/code&gt; itself :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker@minikube:~$ sudo ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/var/lib/minikube/certs/etcd/ca.crt --cert=/var/lib/minikube/certs/etcd/server.crt --key=/var/lib/minikube/certs/etcd/server.key get "/registry/pods/kube-system/etcd-minikube"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6fls9d4aorbnk2xzva17.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6fls9d4aorbnk2xzva17.png" width="800" height="864"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;OK.&lt;/p&gt;

&lt;h3&gt;
  
  
  CustomResourceDefinitions and Kubernetes API
&lt;/h3&gt;

&lt;p&gt;So, when we create a CRD, we extend the Kubernetes API by creating our own API Group with our own name, version, and a new resource type (Kind) that is described in the CRD.&lt;/p&gt;

&lt;p&gt;Documentation — &lt;a href="https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/" rel="noopener noreferrer"&gt;Extend the Kubernetes API with CustomResourceDefinitions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s write a simple CRD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.mycompany.com
spec:
  group: mycompany.com
  names:
    kind: MyApp
    plural: myapps
    singular: myapp
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image:
                  type: string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use the existing API Group &lt;code&gt;apiextensions.k8s.io&lt;/code&gt; and version &lt;code&gt;v1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;from it take the schema of the CustomResourceDefinition object&lt;/li&gt;
&lt;li&gt;and based on this schema, we create our own API Group named &lt;code&gt;mycompany.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;in this API Group, we describe a single resource type  — &lt;code&gt; kind: MyApp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;and one version  — &lt;code&gt; v1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;then using &lt;code&gt;openAPIV3Schema&lt;/code&gt; we describe the schema of our resource - what fields it has and their types, and here you can also set default values (see &lt;a href="https://swagger.io/specification/" rel="noopener noreferrer"&gt;OpenAPI Specification&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With this CRD, we will be able to create new Custom Resources with a manifest in which we pass the &lt;code&gt;apiVersion&lt;/code&gt;, &lt;code&gt;kind&lt;/code&gt;, and &lt;code&gt;spec.image&lt;/code&gt; fields from the &lt;code&gt;schema.openAPIV3Schema.properties.spec.properties.image&lt;/code&gt; of our CRD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: mycompany.com/v1
kind: MyApp
metadata:
  name: example
spec:
  image: nginx:1.25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the CRD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk apply -f test-crd.yaml 
customresourcedefinition.apiextensions.k8s.io/myapps.mycompany.com created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check in the Kubernetes API (you can use the &lt;code&gt;| jq '.groups[] | select(.name == "mycompany.com")'&lt;/code&gt; selector):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:8080/apis/ | jq
...
{
  "name": "mycompany.com",
  "versions": [
    {
      "groupVersion": "mycompany.com/v1",
      "version": "v1"
    }
  ],
  ...
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the API Group &lt;code&gt;mycompany.com&lt;/code&gt; itself :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s localhost:8080/apis/mycompany.com/v1 | jq
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "mycompany.com/v1",
  "resources": [
    {
      "name": "myapps",
      "singularName": "myapp",
      "namespaced": true,
      "kind": "MyApp",
      "verbs": [
        "delete",
        "deletecollection",
        "get",
        "list",
        "patch",
        "create",
        "update",
        "watch"
      ],
      "storageVersionHash": "MZjF6nKlCOU="
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the &lt;code&gt;etcd&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker@minikube:~$ sudo ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/var/lib/minikube/certs/etcd/ca.crt --cert=/var/lib/minikube/certs/etcd/server.crt --key=/var/lib/minikube/certs/etcd/server.key get "" --prefix --keys-only
/registry/apiextensions.k8s.io/customresourcedefinitions/myapps.mycompany.com
...
/registry/apiregistration.k8s.io/apiservices/v1.mycompany.com
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, the &lt;code&gt;/registry/apiextensions.k8s.io/customresourcedefinitions/myapps. mycompany.com&lt;/code&gt; key stores information about the new CRD itself - the CRD structure, its OpenAPI schema, versions, etc., and the &lt;code&gt;/registry/apiregistration.k8s.io/apiservices/v1.mycompany.com&lt;/code&gt; key registers the API Service for this group to access the group via the Kubernetes API.&lt;/p&gt;

&lt;p&gt;And of course, we can see the CRD with kubectl`:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
$ kk get crd&lt;br&gt;
NAME CREATED AT&lt;br&gt;
myapps.mycompany.com 2025-07-12T11:23:19Z&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Create the CustomResource itself from the manifest we wrote above:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
$ kk apply -f test-resource.yaml &lt;br&gt;
myapp.mycompany.com/example created&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Test it:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
$ kk describe MyApp&lt;br&gt;
Name: example&lt;br&gt;
Namespace: default&lt;br&gt;
Labels: &amp;lt;none&amp;gt;&lt;br&gt;
Annotations: &amp;lt;none&amp;gt;&lt;br&gt;
API Version: mycompany.com/v1&lt;br&gt;
Kind: MyApp&lt;br&gt;
Metadata:&lt;br&gt;
  Creation Timestamp: 2025-07-12T13:34:52Z&lt;br&gt;
  Generation: 1&lt;br&gt;
  Resource Version: 4611&lt;br&gt;
  UID: a88e37fd-1477-4a7e-8c00-46c925f510ac&lt;br&gt;
Spec:&lt;br&gt;
  Image: nginx:1.25&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;But this is just data in &lt;code&gt;etcd&lt;/code&gt; for now - we don't have any real Pods resources, because there is no controller that handles resources from &lt;code&gt;Kind: MyApp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; &lt;em&gt;: looking ahead to the next post: actually, Kubernetes Operator is a set of CRDs and a controller that “controls” resources with the specified Kind&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes API Service
&lt;/h3&gt;

&lt;p&gt;When we add a new CRD, Kubernetes not only has to create a new key in &lt;code&gt;etcd&lt;/code&gt; with the new API Group and the corresponding resource schema, but also add a new endpoint to its routes - as we do in Python with &lt;code&gt;@app.get("/")&lt;/code&gt; in FastAPI - so that the API server knows that the &lt;code&gt;GET&lt;/code&gt; request &lt;code&gt;/apis/mycompany.com/v1/myapps&lt;/code&gt; should return resources of this type.&lt;/p&gt;

&lt;p&gt;The corresponding API Service will contain a &lt;code&gt;spec&lt;/code&gt; with the group and version:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;br&gt;
$ kk get apiservice v1.mycompany.com -o yaml&lt;br&gt;
apiVersion: apiregistration.k8s.io/v1&lt;br&gt;
kind: APIService&lt;br&gt;
metadata:&lt;br&gt;
  creationTimestamp: "2025-07-12T11:53:52Z"&lt;br&gt;
  labels:&lt;br&gt;
    kube-aggregator.kubernetes.io/automanaged: "true"&lt;br&gt;
  name: v1.mycompany.com&lt;br&gt;
  resourceVersion: "2632"&lt;br&gt;
  uid: 26fc8c6b-6770-422f-8996-3f35d86be6c7&lt;br&gt;
spec:&lt;br&gt;
  group: mycompany.com&lt;br&gt;
  groupPriorityMinimum: 1000&lt;br&gt;
  version: v1&lt;br&gt;
  versionPriority: 100&lt;br&gt;
...&lt;br&gt;
&lt;/code&gt;&lt;code&gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That is, when we create a new CRD, Kubernetes API Server creates an API Service (writing it to &lt;code&gt;/registry/apiregistration.k8s.io/apiservices/v1.mycompany.com&lt;/code&gt;), and adds it to its own routers in the &lt;code&gt;/apis&lt;/code&gt; endpoint.&lt;/p&gt;

&lt;p&gt;And now, having an idea of what the API looks like and the database that stores all the resources, we can move on to creating the CRD and controller, that is, to actually write the Operator itself.&lt;/p&gt;

&lt;p&gt;But this is already in the next part.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/kubernetes-kubernetes-api-api-groups-crds-and-the-etcd/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Kubernetes: Pod resources.requests, resources.limits and Linux cgroup</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Sun, 14 Sep 2025 22:15:02 +0000</pubDate>
      <link>https://forem.com/setevoy/kubernetes-pod-resourcesrequests-resourceslimits-and-linux-cgroup-4ggp</link>
      <guid>https://forem.com/setevoy/kubernetes-pod-resourcesrequests-resourceslimits-and-linux-cgroup-4ggp</guid>
      <description>&lt;h3&gt;
  
  
  Kubernetes: Pod resources.requests, resources.limits, and Linux cgroups
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flijo0rv8bgvtv6m6x4z6.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;How exactly do &lt;code&gt;resources.requests&lt;/code&gt; and &lt;code&gt;resources.limits&lt;/code&gt; in a Kubernetes manifest works "under the hood", and how exactly will Linux allocate and limit resources for containers?&lt;/p&gt;

&lt;p&gt;So, in Kubernetes for Pods, we can set two main parameters for CPU and Memory  —  the &lt;code&gt;spec.containers.resources.requests&lt;/code&gt; and &lt;code&gt;spec.containers.resources.limits&lt;/code&gt; fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;resources.requests&lt;/code&gt;: affects how and where a Pod will be created and how many resources it is guaranteed to receive&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resources.limits&lt;/code&gt;: affects how many resources it can consume at most if&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resources.limits.memor&lt;/code&gt;y is greater than the limit - the pod &lt;strong&gt;&lt;em&gt;can be&lt;/em&gt;&lt;/strong&gt; killed with OOMKiller if the WorkerNode does not have enough free memory (the Node Memory Pressure state)&lt;/li&gt;
&lt;li&gt;if &lt;code&gt;resources.limits.cpu&lt;/code&gt; is greater than the limit - then CPU throttling mode will be enabled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If everything is quite clear with Memory — we set the number of bytes, then with CPU everything is a little more interesting.&lt;/p&gt;

&lt;p&gt;So first, let’s take a look at how the Linux kernel generally plans how much CPU time will be allocated to each process using the Control Groups mechanism.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Linux cgroups&lt;/li&gt;
&lt;li&gt;The /sys/fs/cgroup/ directory&lt;/li&gt;
&lt;li&gt;CPU and Memory in cgroups, and cgroups v1 vs cgroups v2&lt;/li&gt;
&lt;li&gt;Why are Kubernetes CPU Limits may be a bad idea?&lt;/li&gt;
&lt;li&gt;Linux CFS and cpu.weight&lt;/li&gt;
&lt;li&gt;cpu.weight vs process nice&lt;/li&gt;
&lt;li&gt;Linux cgroups summary&lt;/li&gt;
&lt;li&gt;Kubernetes Pod resources and Linux cgroups&lt;/li&gt;
&lt;li&gt;Kubernetes CPU Unit vs cgroup CPU share&lt;/li&gt;
&lt;li&gt;Checking Kubernetes Pod resources in cgroup&lt;/li&gt;
&lt;li&gt;Kubernetes kubepods.slice cgroup&lt;/li&gt;
&lt;li&gt;Kubernetes, cpu.weight and cgroups v2&lt;/li&gt;
&lt;li&gt;Kubernetes Quality of Service Classes&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Linux &lt;code&gt;cgroups&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Linux &lt;a href="https://docs.kernel.org/admin-guide/cgroup-v1/cgroups.html" rel="noopener noreferrer"&gt;Control Groups&lt;/a&gt; (&lt;code&gt;cgroups&lt;/code&gt;) is one of the two main kernel mechanisms that provide isolation and control over processes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linux namespaces&lt;/strong&gt; : create an isolated namespace with its own process tree (PID Namespace), network interfaces (net namespace), User IDs (User namespace), and so on — see &lt;a href="https://rtfm.co.ua/ru/what-is-linux-namespaces-primery-na-c-clone-pid-i-net-namespaces/" rel="noopener noreferrer"&gt;What is: Linux namespaces, examples of PID and Network namespaces&lt;/a&gt; (in rus)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux cgroups&lt;/strong&gt; : a mechanism for controlling resources by processes — how much memory, CPU, network resources and disk I/O operations will be available to the process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Groups&lt;/em&gt; in the name — because all processes are grouped in a parent-child tree.&lt;/p&gt;

&lt;p&gt;Therefore, if a limit of 512 megabytes is set for a parent process, then the sum of the available memory of it and its children cannot exceed 512 MB.&lt;/p&gt;

&lt;p&gt;All groups are defined in the &lt;code&gt;/sys/fs/cgroup/&lt;/code&gt; directory, which is connected by a separate file system type - &lt;code&gt;cgroup2&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ mount | grep cgro
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate,memory_recursiveprot)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cgroups&lt;/code&gt; has an older version 1 and a newer version 2, see man &lt;a href="https://man7.org/linux/man-pages/man7/cgroups.7.html" rel="noopener noreferrer"&gt;&lt;code&gt;cgroups&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In fact, &lt;code&gt;cgroups&lt;/code&gt; v2 is already a new standard, so we will talk about it - but &lt;code&gt;cgroups&lt;/code&gt; v1 is still present when we talk about Kubernetes.&lt;/p&gt;

&lt;p&gt;You can check the version using &lt;code&gt;stat&lt;/code&gt; and the &lt;code&gt;/sys/fs/cgroup/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ stat -fc %T /sys/fs/cgroup/ 
cgroup2fs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there is &lt;code&gt;tmpfs&lt;/code&gt; here, then this is &lt;code&gt;cgroups&lt;/code&gt; v1.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;/sys/fs/cgroup/&lt;/code&gt; directory
&lt;/h3&gt;

&lt;p&gt;A typical view of a directory on a Linux host — here’s an example from my home laptop running Arch Linux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tree /sys/fs/cgroup/ -d -L 2
/sys/fs/cgroup/
├── dev-hugepages.mount
├── dev-mqueue.mount
├── init.scope
...
├── system.slice
│ ├── NetworkManager.service
│ ├── bluetooth.service
│ ├── bolt.service
    ...
└── user.slice
    └── user-1000.slice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same hierarchy can be seen with &lt;code&gt;systemctl status&lt;/code&gt; or &lt;code&gt;systemd-cgls&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;systemctl status&lt;/code&gt;, the tree looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ systemctl status
● setevoy-work
    State: running
    ...
    Since: Mon 2025-06-09 12:21:11 EEST; 3 weeks 1 day ago
  systemd: 257.6-1-arch
   CGroup: /
           ├─init.scope
           │ └─1 /sbin/init
           ├─system.slice
           │ ├─NetworkManager.service
           │ │ └─858 /usr/bin/NetworkManager --no-daemon
           ...
           │ └─wpa_supplicant.service
           │ └─1989 /usr/bin/wpa_supplicant -u -s -O /run/wpa_supplicant
           └─user.slice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, all processes are grouped by type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;system.slice&lt;/code&gt;: all systemd services (&lt;code&gt;nginx.service&lt;/code&gt;, &lt;code&gt;docker.service&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user.slice&lt;/code&gt;: user processes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;machine.slice&lt;/code&gt;: virtual machines, containers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where &lt;em&gt;slice&lt;/em&gt; is an abstraction of &lt;code&gt;systemd&lt;/code&gt; by which it groups different processes - see man &lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.slice.html" rel="noopener noreferrer"&gt;&lt;code&gt;systemd.slice&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can see which &lt;code&gt;cgroup&lt;/code&gt; a process belongs to in its &lt;code&gt;/proc/&amp;lt;PID&amp;gt;/cgroup&lt;/code&gt;, for example, NetworkManager with PID "858":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /proc/858/cgroup 
0::/system.slice/NetworkManager.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cgroup&lt;/code&gt; slice can also be specified in the systemd file of the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /usr/lib/systemd/system/mdmon@.service | grep Slice 
Slice=system.slice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CPU and Memory in &lt;code&gt;cgroups&lt;/code&gt;, and &lt;code&gt;cgroups&lt;/code&gt; v1 vs &lt;code&gt;cgroups&lt;/code&gt; v2
&lt;/h3&gt;

&lt;p&gt;So, in the &lt;code&gt;cgroup&lt;/code&gt; for the entire slide, you set the parameters of how much CPU and Memory processes of this group can use (hereinafter we will talk only about CPU and Memory).&lt;/p&gt;

&lt;p&gt;For example, for my user &lt;code&gt;setevoy&lt;/code&gt; (with ID 1000), we have the files &lt;code&gt;cpu.max&lt;/code&gt; and &lt;code&gt;memory.max&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /sys/fs/cgroup/user.slice/user-1000.slice/cpu.max
max 100000

$ cat /sys/fs/cgroup/user.slice/user-1000.slice/memory.max 
max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cpu.max&lt;/code&gt; in cgroups v2 replaced &lt;code&gt;cpu.cfs_quota_us&lt;/code&gt; and &lt;code&gt;cpu.cfs_period_us&lt;/code&gt; from cgroup v1&lt;/p&gt;

&lt;p&gt;Here, in &lt;code&gt;cpu.max&lt;/code&gt;, we have the settings for how much CPU time will be devoted to my user's processes.&lt;/p&gt;

&lt;p&gt;The format of the file is &lt;code&gt;&amp;lt;quota&amp;gt; &amp;lt;period&amp;gt;&lt;/code&gt;, where &lt;code&gt;&amp;lt;quota&amp;gt;&lt;/code&gt; is the time available to the process (or group), and &lt;code&gt;&amp;lt;period&amp;gt;&lt;/code&gt; is the duration of one period in microseconds (100,000 µs = 100 ms).&lt;/p&gt;

&lt;p&gt;In cgroups v1, these values were set in &lt;code&gt;cpu.cfs_quota&lt;/code&gt; - for &lt;code&gt;&amp;lt;quota&amp;gt;&lt;/code&gt; in v2, and &lt;code&gt;cpu.cfs_period_us&lt;/code&gt; - for &lt;code&gt;&amp;lt;period&amp;gt;&lt;/code&gt; in v2.&lt;/p&gt;

&lt;p&gt;That is, in the file above we see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;max&lt;/code&gt;: available all the time&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;100000 µs&lt;/code&gt; = 100 ms, one CPU period&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CPU period here is the time interval during which the Linux kernel checks how many processes in the cgroup have used CPU: if the group has a quota and the processes have exhausted it, they will be suspended until the end of the current period*&lt;em&gt;&lt;em&gt;(CPU throttling&lt;/em&gt;&lt;/em&gt;*).&lt;/p&gt;

&lt;p&gt;That is, if a limit of 50,000 (50 ms) is set for a process with a &lt;code&gt;period&lt;/code&gt; of 100,000 microseconds (100 ms), then processes can use only 50 ms in each 100 ms "window".&lt;/p&gt;

&lt;p&gt;Memory usage can be seen in the file &lt;code&gt;memory.current&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /sys/fs/cgroup/user.slice/user-1000.slice/memory.current 
47336714240
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which gives us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ echo "47336714240 / 1024 / 1024" | bc 
45143
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;45 gigabytes of memory occupied by 1000 user processes.&lt;/p&gt;

&lt;p&gt;You can also check the current resource usage of each group with &lt;code&gt;systemd-cgtop&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4whe98bebmkw38qn0p7m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4whe98bebmkw38qn0p7m.png" width="800" height="189"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Or by passing the slice name:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3h3pnkpb5rksyqvc4v3y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3h3pnkpb5rksyqvc4v3y.png" width="800" height="97"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For CPU, there are general statistics for the group from the beginning of the creation of processes in this group  — &lt;code&gt; cpu.stat&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /sys/fs/cgroup/user.slice/user-1000.slice/cpu.stat 
usage_usec 2863938974603 
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Kubernetes, &lt;code&gt;cpu.max&lt;/code&gt; and &lt;code&gt;memory.max&lt;/code&gt; will be determined when we set &lt;code&gt;resources.limits.cpu&lt;/code&gt; and &lt;code&gt;resources.limits.memory&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why are Kubernetes CPU Limits may be a bad idea?
&lt;/h3&gt;

&lt;p&gt;It is often said that setting CPU limits in Kubernetes is a bad idea.&lt;/p&gt;

&lt;p&gt;Why is this so?&lt;/p&gt;

&lt;p&gt;Because if we set a limit (i.e., the value != &lt;code&gt;max&lt;/code&gt; in &lt;code&gt;cpu.max&lt;/code&gt;), then when a group of processes uses up its time in the current CPU Time window, these processes will be limited even though the CPU has the ability to fulfill requests in general.&lt;/p&gt;

&lt;p&gt;That is, even if there are free cores in the system, but cgroup has already exhausted its &lt;code&gt;cpu.max&lt;/code&gt; in the current period, the processes of this group will be suspended until the end of the period &lt;strong&gt;&lt;em&gt;(CPU throttling&lt;/em&gt;&lt;/strong&gt;), regardless of the overall system load.&lt;/p&gt;

&lt;p&gt;See &lt;a href="https://home.robusta.dev/blog/stop-using-cpu-limits" rel="noopener noreferrer"&gt;For the Love of God, Stop Using CPU Limits on Kubernetes&lt;/a&gt;, and &lt;a href="https://medium.com/@jettycloud/making-sense-of-kubernetes-cpu-requests-and-limits-390bbb5b7c92" rel="noopener noreferrer"&gt;Making Sense of Kubernetes CPU Requests And Limits&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Linux CFS and &lt;code&gt;cpu.weight&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Above we saw &lt;code&gt;cpu.max&lt;/code&gt;, where my user is allowed to use all available CPU time for each CPU period.&lt;/p&gt;

&lt;p&gt;But if the limit is not set (i.e. &lt;code&gt;max&lt;/code&gt;), and several groups of processes want access to the CPU at the same time, then the kernel must decide who should be allocated more CPU time.&lt;/p&gt;

&lt;p&gt;To do this, another parameter is set in cgroups  — &lt;code&gt; cpu.weight&lt;/code&gt; (in cgroups v2) or &lt;code&gt;cpu.shares&lt;/code&gt; (in cgroups v1): this is the relative priority of a group of processes when determining the CPU access queue.&lt;/p&gt;

&lt;p&gt;The value of &lt;code&gt;cpu.weight&lt;/code&gt; is taken into account by Linux CFS (Completely Fair Scheduler) to allocate CPU proportionally among several cgroups. - See &lt;a href="https://docs.kernel.org/scheduler/sched-design-CFS.html" rel="noopener noreferrer"&gt;CFS Scheduler&lt;/a&gt; and &lt;a href="https://www.scaler.com/topics/operating-system/process-scheduling/" rel="noopener noreferrer"&gt;Process Scheduling in Linux&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /sys/fs/cgroup/user.slice/user-1000.slice/cpu.weight 
100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The range of values here is from 1 to 10,000, where 1 is the minimum priority and 10,000 is the maximum. The value 100 is the default.&lt;/p&gt;

&lt;p&gt;The higher the priority, the more time CFS will allocate to processes in this group.&lt;/p&gt;

&lt;p&gt;But this is only taken into account when there is a race for CPU time: when the processor is free, all processes get as much CPU time as they need.&lt;/p&gt;

&lt;p&gt;In Kubernetes, &lt;code&gt;cpu.weight&lt;/code&gt; will be determined from &lt;code&gt;resources.requests.cpu&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But the value of &lt;code&gt;resources.requests.memory&lt;/code&gt; only affects the Kubernetes Scheduler to select a Kubernetes WorkerNode to find a node that has enough free memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;cpu.weight&lt;/code&gt; vs process &lt;code&gt;nice&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;In addition to &lt;code&gt;cpu.weight&lt;/code&gt;/&lt;code&gt;cpu.shares&lt;/code&gt;, we also have &lt;strong&gt;&lt;em&gt;process nice&lt;/em&gt;&lt;/strong&gt; , which sets the priority of the task.&lt;/p&gt;

&lt;p&gt;The difference between them is that &lt;code&gt;cpu.weight&lt;/code&gt; is set at the cgroup level, while &lt;code&gt;nice&lt;/code&gt; is set at the level of a specific process within the same group.&lt;/p&gt;

&lt;p&gt;And if a higher value in &lt;code&gt;cpu.weight&lt;/code&gt; indicates a higher priority, then with &lt;code&gt;nice&lt;/code&gt; it is the opposite - the lower the nice value (from -19 to 20 maximum), the more time will be allocated to the process.&lt;/p&gt;

&lt;p&gt;If both processes are in the same cgroup, but with different &lt;code&gt;nice&lt;/code&gt;, then &lt;code&gt;nice&lt;/code&gt; will be taken into account.&lt;/p&gt;

&lt;p&gt;And if these are different cgroups, then &lt;code&gt;cpu.weight&lt;/code&gt; will be taken into account.&lt;/p&gt;

&lt;p&gt;That is, &lt;code&gt;cpu.weight&lt;/code&gt; determines which group of processes is more important to the kernel, and &lt;code&gt;nic&lt;/code&gt;e determines which process in the group has priority.&lt;/p&gt;

&lt;h3&gt;
  
  
  Linux cgroups summary
&lt;/h3&gt;

&lt;p&gt;So, each Control Group determines how much CPU and memory will be allocated to a process.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cpu.max&lt;/code&gt;: determines how much time from each CPU period a process group can spend&lt;/li&gt;
&lt;li&gt;Kubernetes manifest values in &lt;code&gt;resources.limits.cpu&lt;/code&gt; and &lt;code&gt;resources.limits.memory&lt;/code&gt; affect the &lt;code&gt;cpu.max&lt;/code&gt; and &lt;code&gt;memory.max&lt;/code&gt; settings for the cgroup of the corresponding containers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;memory.max&lt;/code&gt;: how much memory can be used without the risk of being killed by the Out of Memory Killer&lt;/li&gt;
&lt;li&gt;Kubernetes manifest value of &lt;code&gt;resources.requests.memory&lt;/code&gt; affects only the Kubernetes Scheduler for selecting a Kubernetes WorkerNode&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cpu.weight&lt;/code&gt;: determines the priority of a group of processes when the CPU is under load&lt;/li&gt;
&lt;li&gt;Kubernetes manifest the value in &lt;code&gt;resources.requests.cpu&lt;/code&gt; affects the &lt;code&gt;cpu.weight&lt;/code&gt; setting for the cgroup of the corresponding containers&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kubernetes Pod resources and Linux cgroups
&lt;/h3&gt;

&lt;p&gt;Okay, now that we’ve figured out cgroups on Linux, let’s take a closer look at how the values in Kubernetes &lt;code&gt;resources.requests&lt;/code&gt; and &lt;code&gt;resources.limits&lt;/code&gt; affect containers.&lt;/p&gt;

&lt;p&gt;When we set &lt;code&gt;spec.container.resources&lt;/code&gt; in Deployment or Pod, and Pods are created on a WorkerNode, the &lt;code&gt;kubelet&lt;/code&gt; on that node gets the values from &lt;code&gt;PodSpec&lt;/code&gt; and passes them to the Container Runtime Interface (CRI) (ContainerD or CRI-O).&lt;/p&gt;

&lt;p&gt;The CRI converts them into a container specification in JSON, which specifies the appropriate values for the cgroup of this container.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes CPU Unit vs cgroup CPU share
&lt;/h3&gt;

&lt;p&gt;In Kubernetes manifests, we specify CPU resources in CPU units: 1 unit == 1 full CPU core — physical or virtual, see &lt;a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#meaning-of-cpu" rel="noopener noreferrer"&gt;CPU resource units&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;1 millicpu or millicores is 1/1000 of one CPU core.&lt;/p&gt;

&lt;p&gt;One Kubernetes CPU Unit is 1024 CPU shares in the corresponding Linux cgroup.&lt;/p&gt;

&lt;p&gt;That is: 1 Kubernetes CPU Unit == 1000 millicpu == 1024 CPU shares in a cgroup.&lt;/p&gt;

&lt;p&gt;In addition, there is a nuance with how Kubernetes calculates the &lt;code&gt;cpu.weight&lt;/code&gt; for Pods - because Kubernetes uses CPU shares, which it then translates into &lt;code&gt;cpu.weight&lt;/code&gt; for cgroup v2 - we will see how it looks like below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking Kubernetes Pod &lt;code&gt;resources&lt;/code&gt; in a cgroup
&lt;/h3&gt;

&lt;p&gt;Let’s create a test Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: v1
kind: Pod
metadata:
  name: nginx-test
  namespace: default
spec:
  containers:
    - name: nginx
      image: nginx
      resources:
        requests:
          cpu: "1"
          memory: "1Gi"
        limits:
          cpu: "1"
          memory: "1Gi"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it and find the appropriate WorkerNode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe pod nginx-test
Name: nginx-test
Namespace: default
Priority: 0
Service Account: default
Node: ip-10-0-32-142.ec2.internal/10.0.32.142
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let’s connect via SSH and take a look at the cgroups settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes &lt;code&gt;kubepods.slice&lt;/code&gt; cgroup
&lt;/h3&gt;

&lt;p&gt;All parameters for Kubernetes Pods are set in the /&lt;code&gt;sys/fs/cgroup/kubepods.slice/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-32-142 ec2-user]# ls -l /sys/fs/cgroup/kubepods.slice/
...
drwxr-xr-x. 5 root root 0 Jul 2 12:30 kubepods-besteffort.slice
drwxr-xr-x. 6 root root 0 Jul 2 12:30 kubepods-burstable.slice
drwxr-xr-x. 4 root root 0 Jul 2 12:31 kubepods-pod32075da9_3540_4960_8677_e3837e04d69f.slice
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To find out which cgroup slice is responsible for our container, let’s check the running pods in the &lt;code&gt;k8s.io&lt;/code&gt; namespace&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-32-142 ec2-user]# ctr -n k8s.io containers ls
CONTAINER IMAGE RUNTIME                  
00d432ee10181ce579af7f0d02a3a04167ced45f8438167f3922e385ed9ab58f 602401143452.dkr.ecr.us-east-1.amazonaws.com/eks/eks-pod-identity-agent:v0.1.29 io.containerd.runc.v2    
...
987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f docker.io/library/nginx:latest io.containerd.runc.v2
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;em&gt;Note&lt;/em&gt;&lt;/strong&gt; _: the namespace in &lt;code&gt;ctr&lt;/code&gt; are containerd namespaces, not Linux ones, see &lt;a href="https://blog.mobyproject.org/containerd-namespaces-for-docker-kubernetes-and-beyond-d6c43f565084" rel="noopener noreferrer"&gt;containerd namespaces for Docker, Kubernetes, and beyond_&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our container is_”987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f_”.&lt;/p&gt;

&lt;p&gt;We check all the information on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-32-142 ec2-user]# ctr -n k8s.io containers info 987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f
...
        "linux": {
            "resources": {
                "devices": [
                    {
                        "allow": false,
                        "access": "rwm"
                    }
                ],
                "memory": {
                    "limit": 1073741824,
                    "swap": 1073741824
                },
                "cpu": {
                    "shares": 1024,
                    "quota": 100000,
                    "period": 100000
                },
                "unified": {
                    "memory.oom.group": "1",
                    "memory.swap.max": "0"
                }
            },
            "cgroupsPath": "kubepods-pod32075da9_3540_4960_8677_e3837e04d69f.slice:cri-containerd:987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f",
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we see &lt;code&gt;resources.memory&lt;/code&gt; and &lt;code&gt;resources.cpu&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Everything is clear with &lt;code&gt;memory&lt;/code&gt;, and in &lt;code&gt;resources.cpu&lt;/code&gt; we have three fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;shares&lt;/code&gt;: these are our &lt;code&gt;requests&lt;/code&gt; from the Pod manifest (&lt;code&gt;PodSpec&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quota&lt;/code&gt;: these are our &lt;code&gt;limits&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;period&lt;/code&gt;: CPU period, which was mentioned above - the "accounting window" for CFS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the &lt;code&gt;cgroupsPath&lt;/code&gt; we see which cgroup slice contains information about this container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-32-142 ec2-user]# ls -l /sys/fs/cgroup/kubepods.slice/kubepods-pod32075da9_3540_4960_8677_e3837e04d69f.slice/cri-containerd-987bb39fa50532a89842fe1b7a21d1a5829cdf10949a11ac2d4f30ce4afcca2f.scope/
...
-rw-r--r--. 1 root root 0 Jul 2 12:31 cpu.idle
-rw-r--r--. 1 root root 0 Jul 2 12:31 cpu.max
...
-rw-r--r--. 1 root root 0 Jul 2 12:31 cpu.weight
...
-rw-r--r--. 1 root root 0 Jul 2 12:31 memory.max
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the corresponding values in them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-32-142 ec2-user]# cat /sys/fs/cgroup/kubepods.slice/kubepods-pod[...]cca2f.scope/cpu.max
100000 100000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is, a maximum of 100,000 microseconds from each window of 100,000 microseconds  —  because we set &lt;code&gt;resources.limits.cpu&lt;/code&gt; == "1", i.e. "full kernel".&lt;/p&gt;

&lt;h4&gt;
  
  
  Kubernetes, &lt;code&gt;cpu.weight&lt;/code&gt; and cgroups v2
&lt;/h4&gt;

&lt;p&gt;But if we take a look at the &lt;code&gt;cpu.weight&lt;/code&gt; file, the picture is as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-32-142 ec2-user]# cat /sys/fs/cgroup/kubepods.slice/kubepods-pod[...]cca2f.scope/cpu.weight 
39
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where did the value “39” come from?&lt;/p&gt;

&lt;p&gt;In the container description, we saw &lt;code&gt;shares&lt;/code&gt; == 1024:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
        "linux": {
            "resources": {
                ...
                "cpu": {
                    "shares": 1024,
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cpu.shares&lt;/code&gt; 1024 is the value we set in Kubernetes when we specified &lt;code&gt;resources.requests.cpu&lt;/code&gt; == "1", because, as mentioned above, "One Kubernetes CPU Unit is 1024 CPU shares".&lt;/p&gt;

&lt;p&gt;That is, for cgroups v1 — in the &lt;code&gt;cpu.shares&lt;/code&gt; file we would have a value of 1024.&lt;/p&gt;

&lt;p&gt;But cgroup v2 is a bit more interesting.&lt;/p&gt;

&lt;p&gt;“Under the hood, Kubernetes still counts CPU Shares in the format 1 core == 1024 shares, which are then translated into the cgroups v2 format.&lt;/p&gt;

&lt;p&gt;If we look at the total &lt;code&gt;cpu.weights&lt;/code&gt; for the entire &lt;code&gt;kubepods.slice&lt;/code&gt;, we will see a value of 76:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-32-142 ec2-user]# cat /sys/fs/cgroup/kubepods.slice/cpu.weight 
76
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where did the “76” come from?&lt;/p&gt;

&lt;p&gt;Let’s check the number of cores on this instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-32-142 ec2-user]# lscpu | grep -E '^CPU\('
CPU(s): 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The formula for calculating cpu.weight is described in the file &lt;a href="https://github.com/kubernetes/kubernetes/blob/release-1.27/pkg/kubelet/cm/cgroup_manager_linux.go#L566" rel="noopener noreferrer"&gt;&lt;code&gt;group_manager_linux.go#L566&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
func CpuSharesToCpuWeight(cpuShares uint64) uint64 {
  return uint64((((cpuShares - 2) * 9999) / 262142) + 1)
}
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Having 2 cores == 2048 CPU shares for v1 — we calculate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;((((2048 - 2) * 9999) / 262142) + 1) 
79
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is, the entire &lt;code&gt;kubepods.slice&lt;/code&gt; is assigned a "weight" of 79 &lt;code&gt;cpu.weight&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But we counted all CPU shares in general   — and in fact, part of the CPU is reserved for the system and controllers like &lt;code&gt;kubelet&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Kubernetes Quality of Service Classes
&lt;/h4&gt;

&lt;p&gt;See &lt;a href="https://rtfm.co.ua/en/kubernetes-evicted-pods-and-pods-quality-of-service/" rel="noopener noreferrer"&gt;Kubernetes: Evicted pods and Pods Quality of Service&lt;/a&gt;, and &lt;a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/" rel="noopener noreferrer"&gt;Pod Quality of Service Classes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the directory &lt;code&gt;/sys/fs/cgroup/kubepods.slice/&lt;/code&gt; we have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kubepods-besteffort.slice&lt;/code&gt;: &lt;strong&gt;BestEffort QoS&lt;/strong&gt; : when &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt; are specified, but &lt;code&gt;limits&lt;/code&gt; are less than &lt;code&gt;requests&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubepods-burstable.slice&lt;/code&gt;: for &lt;strong&gt;Burstable QoS&lt;/strong&gt; - when only &lt;code&gt;requests&lt;/code&gt; are specified&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubepods-pod32075da9_3540_4960_8677_e3837e04d69f.slice&lt;/code&gt;: &lt;strong&gt;Quarateed QoS&lt;/strong&gt; - when &lt;code&gt;requests&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt; are set and equal to each other&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our Pod is exactly &lt;em&gt;Quarateed QoS&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe pod nginx-test | grep QoS
QoS Class: Guaranteed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because we set &lt;code&gt;requests&lt;/code&gt; == &lt;code&gt;limits&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And since we set 1 full core in &lt;code&gt;requests&lt;/code&gt;, Kubernetes through cgroups allocates it half of the total &lt;code&gt;cpu.weight&lt;/code&gt; available for &lt;code&gt;kubepods.slice&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;38*2 76
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the value we saw in &lt;code&gt;kubepods.slice/cpu.weight&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That’s why Linux CFS will always give our container half of the available CPU time on both cores   — or “one whole core”.&lt;/p&gt;

&lt;h3&gt;
  
  
  Useful links
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://victoriametrics.com/blog/kubernetes-cpu-go-gomaxprocs/#cpu-weight" rel="noopener noreferrer"&gt;How CPU Weight Is Calculated&lt;/a&gt; on VictoriaMetrics blogs&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/@jettycloud/making-sense-of-kubernetes-cpu-requests-and-limits-390bbb5b7c92" rel="noopener noreferrer"&gt;Making Sense of Kubernetes CPU Requests And Limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://martinheinz.dev/blog/91" rel="noopener noreferrer"&gt;Cgroups — Deep Dive into Resource Management in Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#meaning-of-cpu" rel="noopener noreferrer"&gt;Resource Management for Pods and Containers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.archlinux.org/title/Cgroups" rel="noopener noreferrer"&gt;cgroups&lt;/a&gt; (Arch Wiki)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://linuxera.org/cpu-memory-management-kubernetes-cgroupsv2/" rel="noopener noreferrer"&gt;CPU and Memory Management on Kubernetes with Cgroupsv2&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/kubernetes-pod-resources-requests-resources-limits-ta-linux-cgroup/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>linux</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>AWS: introduction to the OpenSearch Service as a vector store</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Sun, 14 Sep 2025 22:07:32 +0000</pubDate>
      <link>https://forem.com/aws-heroes/aws-introduction-to-the-opensearch-service-as-a-vector-store-55na</link>
      <guid>https://forem.com/aws-heroes/aws-introduction-to-the-opensearch-service-as-a-vector-store-55na</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj65j5emcorj18ix3o5qr.png" width="640" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We are currently using AWS OpenSearch Service as a vector store for our RAG with AWS Bedrock Knowledge Base.&lt;/p&gt;

&lt;p&gt;We will talk more about RAG and Bedrock another time, but today let’s take a look at AWS OpenSearch Service.&lt;/p&gt;

&lt;p&gt;The task is to migrate our AWS OpenSearch Service Serverless to Managed, primarily due to (surprise) cost issues — because with Serverless, we constantly have unexpected spikes in OpenSearch Compute Units (OCU — processor, memory, and disk) usage, even when there are no changes in the data.&lt;/p&gt;

&lt;p&gt;The main task is to plan the cluster size: disks, CPU and memory, and select the appropriate instance types.&lt;/p&gt;

&lt;p&gt;In the second part, we will talk about access settings — &lt;a href="https://rtfm.co.ua/en/aws-creating-an-opensearch-service-cluster-and-configuring-authentication-and-authorization/" rel="noopener noreferrer"&gt;AWS: creating an OpenSearch Service cluster and setting up authentication and authorization&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Elasticsearch vs OpenSearch vs AWS OpenSearch Service&lt;/li&gt;
&lt;li&gt;AWS OpenSearch Service: an intro&lt;/li&gt;
&lt;li&gt;Data schema: documents, indexes, and shards&lt;/li&gt;
&lt;li&gt;Data, Master, and Coordinator Nodes&lt;/li&gt;
&lt;li&gt;Pricing&lt;/li&gt;
&lt;li&gt;Hot, UltraWarm, and Cold storage in the OpenSearch Service&lt;/li&gt;
&lt;li&gt;Planning an AWS OpenSearch Service domain&lt;/li&gt;
&lt;li&gt;Storage&lt;/li&gt;
&lt;li&gt;Choosing the size of disks&lt;/li&gt;
&lt;li&gt;Number of shards&lt;/li&gt;
&lt;li&gt;Choosing a type of Data Nodes&lt;/li&gt;
&lt;li&gt;Instance types&lt;/li&gt;
&lt;li&gt;Data Node Storage&lt;/li&gt;
&lt;li&gt;Data Node CPU&lt;/li&gt;
&lt;li&gt;Data Node RAM&lt;/li&gt;
&lt;li&gt;Calculating RAM for logs&lt;/li&gt;
&lt;li&gt;RAM calculation for vector store&lt;/li&gt;
&lt;li&gt;RAG, AWS Bedrock Knowledge Base, data, and vector creation&lt;/li&gt;
&lt;li&gt;Number of vectors&lt;/li&gt;
&lt;li&gt;Useful links&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Elasticsearch vs OpenSearch vs AWS OpenSearch Service
&lt;/h3&gt;

&lt;p&gt;In fact, OpenSearch is essentially the same as Elasticsearch: when Elasticsearch changed its license terms in 2021, AWS launched its own fork, naming it &lt;a href="https://docs.opensearch.org/latest/" rel="noopener noreferrer"&gt;OpenSearch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;OpenSearch is compatible with Elasticsearch up to version 7.10, but unlike Elasticsearch, OpenSearch has a completely free license.&lt;/p&gt;

&lt;p&gt;I once wrote about launching Elasticsearch as part of the ELK stack for logs here — &lt;a href="https://rtfm.co.ua/en/elastic-stack-an-overview-and-elk-installation-on-ubuntu-20-04/" rel="noopener noreferrer"&gt;Elastic Stack: overview and installation of ELK on Ubuntu&lt;/a&gt; (2022), but that article is more about self-hosted solutions and working with indexes in general. Now we will look specifically at the solution from AWS.&lt;/p&gt;

&lt;p&gt;AWS OpenSearch Service is a fully AWS-managed service: as with Kubernetes, AWS takes care of all deployment, updates, and backups, and has tight integration with other AWS services — IAM, VPC, S3, and Bedrock, which is what we use it with.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS OpenSearch Service: an intro
&lt;/h3&gt;

&lt;p&gt;Here and below, I will mainly talk about the Managed OpenSearch Service.&lt;/p&gt;

&lt;p&gt;The basic concepts of AWS OpenSearch Service are &lt;em&gt;domains&lt;/em&gt;, &lt;em&gt;nodes&lt;/em&gt;, &lt;em&gt;indexes&lt;/em&gt; (“databases”), and &lt;em&gt;shards&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A “domain” is the cluster itself, which we configure to the desired number and type of nodes, and indexes are divided into shards (data blocks) that are distributed among the nodes:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxb8f7780jedcm128gatq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxb8f7780jedcm128gatq.png" width="800" height="549"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The nodes in the cluster are essentially regular EC2 instances (as in RDS or even AWS Load Balancer), where the same regular compute instances run under the hood.&lt;/p&gt;

&lt;p&gt;For the AWS OpenSearch Service cluster, as with Elastic Kubernetes Service, separate control nodes (&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-dedicatedmasternodes.html" rel="noopener noreferrer"&gt;master nodes&lt;/a&gt;) are created, but unlike EKS, we do not need to manage the Data Plane and WorkerNodes separately here.&lt;/p&gt;

&lt;p&gt;As with RDS, we can set up automatic backups for the OpenSearch cluster.&lt;/p&gt;

&lt;p&gt;For data visualization, AWS provides &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/dashboards.html" rel="noopener noreferrer"&gt;OpenSearch Dashboards&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data schema: documents, indexes, and shards
&lt;/h3&gt;

&lt;p&gt;To understand what types of instances to choose for our cluster, let’s take a look at what &lt;em&gt;indexes&lt;/em&gt; are in OpenSearch (or Elasticsearch, because they are essentially the same thing).&lt;/p&gt;

&lt;p&gt;So, an index is a collection of documents that have some common features. Each index has a unique name, just like a database in RDS PostgreSQL or MariaDB.&lt;/p&gt;

&lt;p&gt;Although an index is often compared to a database, in practice it is more convenient to think of an index as a table, and the “database” as the entire cluster.&lt;/p&gt;

&lt;p&gt;A document is a JSON object in an index and represents a basic unit of data storage. If we draw an analogy with the SQL databases, it is like a row in a table.&lt;/p&gt;

&lt;p&gt;Each document has a set of key-value fields, where values can be strings, integers, dates, or more complex structures such as arrays or objects.&lt;/p&gt;

&lt;p&gt;Indexes are divided into parts called &lt;em&gt;shards&lt;/em&gt; for better performance, where each shard contains a portion of the index data. Each document is stored in only one shard, and searches can be performed in parallel across multiple shards.&lt;/p&gt;

&lt;p&gt;Although technically not entirely accurate, shards can be thought of as separate mini-indexes or mini-databases.&lt;/p&gt;

&lt;p&gt;Shards can be &lt;em&gt;primary&lt;/em&gt; or &lt;em&gt;replica&lt;/em&gt;: primary accepts all write operations and can process select, while replica is only for read-only operations.&lt;/p&gt;

&lt;p&gt;At the same time, a replica is always created on another data node for fault tolerance, and a replica can become primary if the node with the primary shard fails.&lt;/p&gt;

&lt;p&gt;The default value for the number of shards per index in AWS OpenSearch Service is 5, but it can be configured separately (i.e., with 5 primary shards, we will have 10 shards in total, because there will also be replicas).&lt;/p&gt;

&lt;p&gt;It is recommended that shards be between 10 and 50 gigabytes in size: each shard requires CPU and memory to work with it, so a large number of small shards will increase the need for resources, while shards that are too large will slow down operations on them.&lt;/p&gt;

&lt;p&gt;In the open-source OpenSearch (and Elasticsearch), the default number of primary shards is 1.&lt;/p&gt;

&lt;p&gt;New documents are distributed evenly among all available shards.&lt;/p&gt;

&lt;p&gt;Related:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/blogs/big-data/amazon-opensearch-service-101-how-many-shards-do-i-need/" rel="noopener noreferrer"&gt;Amazon OpenSearch Service 101: How many shards do I need&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Data, Master, and Coordinator Nodes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Data Nodes&lt;/strong&gt;  — store data and shards, and execute search and aggregation queries. These are the main “working units” of the cluster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-dedicatedmasternodes.html" rel="noopener noreferrer"&gt;&lt;strong&gt;Master Nodes&lt;/strong&gt;&lt;/a&gt; — store metadata about indexes, mapping, cluster status, manage primary/replica shards, perform rebalancing — but do not process search queries. That is, their task is exclusively to control the cluster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/Dedicated-coordinator-nodes.html" rel="noopener noreferrer"&gt;&lt;strong&gt;Coordinator nodes&lt;/strong&gt;&lt;/a&gt; (client nodes) do not store any data and do not participate in its processing. The role of these nodes is to act as a kind of “proxy” between the client and the data nodes. They receive a query from the client, divide it into subqueries (&lt;em&gt;scattering&lt;/em&gt;), send them to the appropriate data nodes, then collect the result (gather) and return it to the client. However, it is advisable to have separate nodes under Coordinators on large clusters in order to reduce the load on Master and Data nodes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pricing
&lt;/h3&gt;

&lt;p&gt;As with most similar AWS services, we pay for compute resources (CPU, RAM) per disk (EBS) and for traffic — although traffic has some nuances (for the better) — because for multi-AZ deployments, we don’t pay for traffic between nodes in different Availability Zones (in RDS, too, I think), nor do we pay for traffic between UltraWarm/Cold Nodes and AWS S3.&lt;/p&gt;

&lt;p&gt;Full documentation on pricing is available at &lt;a href="https://aws.amazon.com/opensearch-service/pricing/" rel="noopener noreferrer"&gt;Amazon OpenSearch Service Pricing&lt;/a&gt;, and here are the main points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;t3.medium.search&lt;/code&gt;: 2 vCPU, 4 GB RAM - $0.073 (regular &lt;code&gt;t3.medium&lt;/code&gt; EC2 will cost less - $0.044)&lt;/li&gt;
&lt;li&gt;General Purpose SSD (gp3) EBS: $0.122 per GB/month (regular EBS for EC2 — $0.08/GB-month)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Similar to AWS EKS, OpenSearch Service has two types of update support — Standard and extended — and, of course, Extended will be more expensive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hot, UltraWarm, and Cold storage in the OpenSearch Service
&lt;/h3&gt;

&lt;p&gt;Data (indexes) storage in OpenSearch Service can be organized either on EBS on the data node itself (Hot), cached on a node with a “backend” in S3 (UltraWarm), or only in S3 (Cold):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hot storage&lt;/strong&gt; : regular data nodes on regular EC2 with EBS — for the most relevant data, provides fast access to data&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ultrawarm.html" rel="noopener noreferrer"&gt;&lt;strong&gt;UltraWarm storage&lt;/strong&gt;&lt;/a&gt;: for data that is still relevant but not frequently accessed — data is stored in S3, and their cache is stored on the nodes, with the nodes themselves being a separate type of instance, such as &lt;code&gt;ultrawarm1.medium.search&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;fast access to data that is in the cache, slower access to data that has not been accessed for a long time&lt;/li&gt;
&lt;li&gt;the nodes themselves are more expensive (&lt;code&gt;ultrawarm1.medium.search&lt;/code&gt; will cost $0.238), but savings are achieved by storing data in S3 instead of EBS&lt;/li&gt;
&lt;li&gt;read-only data&lt;/li&gt;
&lt;li&gt;not available whether there are T2 or T3 instances in the cluster :-(&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/cold-storage.html" rel="noopener noreferrer"&gt;&lt;strong&gt;Cold storage&lt;/strong&gt;&lt;/a&gt;: this data is stored exclusively in S3 and can be accessed via the OpenSearch Service API&lt;/li&gt;
&lt;li&gt;slow access, but here we only pay for S3&lt;/li&gt;
&lt;li&gt;to use it, you need to have the Warm storage configured&lt;/li&gt;
&lt;li&gt;similarly, not available if there are T2 or T3 instances in the cluster :-(&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is well described in &lt;a href="https://aws.amazon.com/blogs/big-data/choose-the-right-storage-tier-for-your-needs-in-amazon-opensearch-service/" rel="noopener noreferrer"&gt;Choose the right storage tier for your needs in Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Automatic backups are free and stored for 14 days.&lt;/p&gt;

&lt;p&gt;Manual backups are charged for S3 storage, but there is no charge for the traffic used to store them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Planning an AWS OpenSearch Service domain
&lt;/h3&gt;

&lt;p&gt;OK, now that we’ve covered the basics, let’s think about how we’re going to build the cluster — its capacity planning and the selection of instance types for Data Nodes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Choosing the size of disks
&lt;/h3&gt;

&lt;p&gt;A very important point to start with is to determine how much space your index or indexes will take up.&lt;/p&gt;

&lt;p&gt;This is well described in the &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp-storage.html" rel="noopener noreferrer"&gt;Calculating storage requirements&lt;/a&gt; documentation, but let’s calculate it ourselves.&lt;/p&gt;

&lt;p&gt;For example, we will have 3 data nodes, and we will store some logs.&lt;/p&gt;

&lt;p&gt;We record 10 GiB of logs per day, which we store for 30 days, resulting in 300 gigabytes of occupied space. With three nodes, that’s 100 gigabytes per node.&lt;/p&gt;

&lt;p&gt;But we also need to consider the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Number of replicas&lt;/strong&gt; : each replica shard is a copy of the primary shard, so it will take up about the same amount of space&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch indexing overhead&lt;/strong&gt; : OpenSearch takes up additional space for its own indexes; this is another +10% of the size of the data itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operating system reserved space&lt;/strong&gt; : 5% of space on EBS is reserved by the operating system&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch Service overhead&lt;/strong&gt; : another 20% — but no more than 20 gigabytes — is reserved on &lt;strong&gt;each node&lt;/strong&gt; by OpenSearch Service itself for its own work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The documentation provides an interesting clarification on the last point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if we have 3 nodes, each with a 500 GB disk, then together we will have 1.5 terabytes, while the total maximum amount of space reserved for OpenSearch will be 60 GB — 20 GB for each node&lt;/li&gt;
&lt;li&gt;if we have 10 nodes, each with a 100 GB disk, then together we will have 1 terabyte, but the maximum amount of space reserved for OpenSearch will be 200 GB — 20 per node.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The formula for calculating space looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Source data * (1 + number of replicas) * (1 + indexing overhead) / (1 - Linux reserved space) / (1 - OpenSearch Service overhead) = minimum storage requirement
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is, if we need to store 300 GB of logs, we calculate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Source data: 300 GiB&lt;/li&gt;
&lt;li&gt;1 primary + 1 replica&lt;/li&gt;
&lt;li&gt;1 + indexing overhead = 1.1 (+10% of 1)&lt;/li&gt;
&lt;li&gt;1 — Linux reserved space = 0.95 (5%)&lt;/li&gt;
&lt;li&gt;1 — OpenSearch Service overhead = 0.8 (but this is true if the disks are less than 100 GB)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this case, for our 300 GiB of logs, we need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;300*2*1.1/0.95/0.8
867
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;867 GiB of total space.&lt;/p&gt;

&lt;p&gt;Or there is a simpler formula — just use a coefficient of 1.45:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Source data * (1 + number of replicas) * 1.45 = minimum storage requirement
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it turns out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;300*2*1.45
870.00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost the same 867 gigabytes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Number of shards
&lt;/h3&gt;

&lt;p&gt;The second important point, which is also described in the documentation, is &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp-sharding.html" rel="noopener noreferrer"&gt;Choosing the number of shards&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The essence is that in AWS OpenSearch Service, the index is split into 5 primary shards without replicas by default (in self-hosted Elasticsearch/OpenSearch, the default is 1 primary and 1 replica).&lt;/p&gt;

&lt;p&gt;Once the index is created, you cannot simply change the number of shards, because the routing of requests to documents is tied to specific shards (this is well described here: &lt;a href="https://codingexplained.com/coding/elasticsearch/understanding-sharding-in-elasticsearch" rel="noopener noreferrer"&gt;Distributing Documents across Shards (Routing)&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The recommended shard size is 10–30 GiB for data with more searches and 30–50 for indexes with more write operations.&lt;/p&gt;

&lt;p&gt;Indexing overhead, which we mentioned above, must be added to the size of the index itself — 10%.&lt;/p&gt;

&lt;p&gt;If we consider a case where we write logs (i.e., write-intensive workload), the maximum index size will be 300 GiB + 10% == 330 GiB.&lt;/p&gt;

&lt;p&gt;If we want to have primary shards of, say, 30 gigabytes, we get 11 primary shards.&lt;/p&gt;

&lt;p&gt;Changing the number of primary shards requires creating a new index and performing a reindex — copying data from the old index to the new one, see &lt;a href="https://opensearch.org/blog/optimize-opensearch-index-shard-size/" rel="noopener noreferrer"&gt;Optimize OpenSearch index shard sizes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;See also &lt;a href="https://aws.amazon.com/blogs/big-data/amazon-opensearch-service-101-how-many-shards-do-i-need/" rel="noopener noreferrer"&gt;Amazon OpenSearch Service 101: How many shards do I need&lt;/a&gt; and &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp.html#bp-sharding-strategy" rel="noopener noreferrer"&gt;Shard strategy&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But!&lt;/p&gt;

&lt;p&gt;If the index is planned to be small, it is better to have one shard + 1 replica; otherwise, the cluster will create unnecessary empty shards that still consume resources.&lt;/p&gt;

&lt;p&gt;In this case, it is still recommended to have three nodes: one will be the primary shard, the second will be the replica, and the third will be the backup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if node-1 with the primary fails, node-2 will make the replica the new primary&lt;/li&gt;
&lt;li&gt;and node-3 will receive a new replica&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Choosing a type of Data Nodes
&lt;/h3&gt;

&lt;p&gt;Another important point is how to choose the right type of data node.&lt;/p&gt;

&lt;p&gt;What we need to understand in order to choose a node are the CPU, RAM, and disk requirements.&lt;/p&gt;

&lt;p&gt;The documentation &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp-instances.html" rel="noopener noreferrer"&gt;Choosing instance types and testing&lt;/a&gt; states:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;try starting with a configuration closer to 2 vCPU cores and 8 GiB of memory for every 100 GiB of your storage requirement&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But this is just for “starting”, and it is recommended to run some load tests and monitor the results.&lt;/p&gt;

&lt;p&gt;We will talk about monitoring separately, but for now, let’s try to make our own estimate for the hardware we need.&lt;/p&gt;

&lt;p&gt;Another useful resource is here: &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp.html#bp-sharding-strategy" rel="noopener noreferrer"&gt;Operational best practices for Amazon OpenSearch Service&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Instance types
&lt;/h3&gt;

&lt;p&gt;See &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/supported-instance-types.html" rel="noopener noreferrer"&gt;Supported instance types in Amazon OpenSearch Service&lt;/a&gt; and &lt;a href="https://aws.amazon.com/opensearch-service/pricing/" rel="noopener noreferrer"&gt;Amazon OpenSearch Service Pricing&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The general rules here are the same as for regular EC2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;General Purpose&lt;/strong&gt; (&lt;code&gt;t3&lt;/code&gt;, &lt;code&gt;m7g&lt;/code&gt;, &lt;code&gt;m7i&lt;/code&gt;): standard servers with balanced CPU/RAM&lt;/li&gt;
&lt;li&gt;well suited for master nodes or data nodes on small clusters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute Optimized&lt;/strong&gt; (&lt;code&gt;c7g&lt;/code&gt;, &lt;code&gt;c7i&lt;/code&gt;): more CPU, less memory&lt;/li&gt;
&lt;li&gt;suitable for data nodes that need more CPU (indexing, complex searches, and aggregations)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory Optimized&lt;/strong&gt; (&lt;code&gt;r7g&lt;/code&gt;, &lt;code&gt;r7gd&lt;/code&gt;, &lt;code&gt;r7i&lt;/code&gt;): conversely, more memory, less CPU&lt;/li&gt;
&lt;li&gt;suitable for data nodes that need more RAM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage Optimized&lt;/strong&gt; (&lt;code&gt;i4g&lt;/code&gt;, &lt;code&gt;i4i&lt;/code&gt;): better SSDs (NVMe SSDs) with high IOPS&lt;/li&gt;
&lt;li&gt;suitable for data nodes that need to perform many write operations (logs, metrics)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch Optimized&lt;/strong&gt; (&lt;code&gt;om2&lt;/code&gt;, &lt;code&gt;or2&lt;/code&gt;): "tuned" instances from AWS itself with an optimal CPU/RAM ratio and disks, easier to configure&lt;/li&gt;
&lt;li&gt;this is something for rich and large clusters :-)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Indexes here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;g&lt;/code&gt;: Gravitor processors (ARM64 from AWS) - productive for multi-threaded computations, better in terms of price:performance, but there may be compatibility issues&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;i&lt;/code&gt;: Intel (based on x86 - classic, compatible with everything, better for heavy single-threaded computations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;d&lt;/code&gt;: "drive" - has an additional NVMe SSD&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Data Node Storage
&lt;/h3&gt;

&lt;p&gt;We seem to have figured out the disk in &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp-sharding.html" rel="noopener noreferrer"&gt;Choosing the number of shards&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;10–30 gigabytes per shard if we plan to have more search operations&lt;/li&gt;
&lt;li&gt;30–50 GiB per shard if there are more write operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next, we select the instance type so that it has enough storage, because there is still a limit on disk size — see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/limits.html" rel="noopener noreferrer"&gt;EBS volume size quotas&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Node CPU
&lt;/h3&gt;

&lt;p&gt;In the &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/bp.html#bp-sharding-strategy" rel="noopener noreferrer"&gt;Shard to CPU ratio&lt;/a&gt; section, there is a recommendation to plan for “&lt;em&gt;1.5 vCPU per shard”&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That is, if we plan to have 4 shards per data node, we allocate 6 vCPUs. We can add 1 (preferably 2) more cores for the needs of the operating system itself.&lt;/p&gt;

&lt;p&gt;However, again, a lot depends on how the data will be processed.&lt;/p&gt;

&lt;p&gt;If there are many search-heavy operations, then 1.5 CPU per shard is quite justified.&lt;/p&gt;

&lt;p&gt;For write-intensive operations, you can consider 0.5 CPU per shard, and for warm and cold nodes, even less.&lt;/p&gt;

&lt;p&gt;See &lt;a href="https://opster.com/guides/opensearch/opensearch-basics/threadpool/" rel="noopener noreferrer"&gt;OpenSearch Threadpool&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Node RAM
&lt;/h3&gt;

&lt;p&gt;Now for the most interesting part: how do we calculate the required memory?&lt;/p&gt;

&lt;p&gt;Here, the calculations will depend heavily on the type of index and data — whether it is simply documents in the form of logs or, as in our case, a vector store.&lt;/p&gt;

&lt;p&gt;Before we calculate the requirements, let’s take a quick look at how memory is distributed on the instance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JVM Heap Size: by default, it is set to 50% of RAM (but no more than 32 gigabytes): in the JVM Heap, we will have various OpenSearch proprietary data — metadata and shard/index management (mappings, routing, cluster status), query and response objects, search coordination, various internal caches and buffers — that is, purely internal needs of OpenSearch itself&lt;/li&gt;
&lt;li&gt;off-heap memory (the operating system’s own memory):&lt;/li&gt;
&lt;li&gt;when using the index as a vector store — HNSW graphs (k-NN search) + Linux page cache for data that is loaded from disk into OS memory for fast access&lt;/li&gt;
&lt;li&gt;for simple logs — only Linux page cache for data that is loaded from disk into OS memory&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Calculating RAM for logs
&lt;/h4&gt;

&lt;p&gt;We plan to allocate 16 gigabytes for the JVM heap, keeping in mind that this will be 50%. Alternatively, we could allocate at least 8 gigabytes and then monitor &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-cloudwatchmetrics.html#managedomains-cloudwatchmetrics-cluster-metrics" rel="noopener noreferrer"&gt;&lt;code&gt;JVMMemoryPressure&lt;/code&gt;&lt;/a&gt; (we will speak about monitoring more in a following blog post, it's already in drafts).&lt;/p&gt;

&lt;p&gt;Next, we estimate the memory under off-heap — Linux will do &lt;code&gt;mmap&lt;/code&gt; relevant for processing data requests (read data blocks from disk into memory when the process requests them).&lt;/p&gt;

&lt;p&gt;Here we will have the “hot data” — that is, the data that is often needed by clients. For example, we know that most often we will be searching the logs for the last 24 hours, and we write 10 gigabytes of logs per day.&lt;/p&gt;

&lt;p&gt;To these 10 GB, we should add 10–50 percent for the OpenSearch structures themselves, so the index will grow by 11–15 GB per day.&lt;/p&gt;

&lt;p&gt;Of these 11–12 gigabytes, let’s say 50% will be actively used for search results — we’ll allocate 5–6 GiB of RAM for the “hot OS page cache”.&lt;/p&gt;

&lt;h4&gt;
  
  
  RAM calculation for vector store
&lt;/h4&gt;

&lt;p&gt;If we use OpenSearch as a vector database, we need to consider the memory requirements for each graph for data search.&lt;/p&gt;

&lt;p&gt;The size of the graph depends on the algorithm, but let’s take the default one — HNSW (Hierarchical Navigable Small Worlds). The choice of algorithm is well described in &lt;a href="https://aws.amazon.com/blogs/big-data/choose-the-k-nn-algorithm-for-your-billion-scale-use-case-with-opensearch/" rel="noopener noreferrer"&gt;Choose the k-NN algorithm for your billion-scale use case with OpenSearch&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In order to estimate how much memory the HNSW structure will take up, we need to know the number of vectors in the index, their dimension (&lt;em&gt;embedding dimension&lt;/em&gt;), and the number of connections between each node in the graph (how many neighbors to store for each point in this graph).&lt;/p&gt;

&lt;p&gt;What do we have in the “vector” anyway?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a set of numbers specified in the dimension embedding model (&lt;code&gt;[0.12, -0.88, ...]&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;metadata: various key:value pairs with information about which document this vector belongs to, source, and so on&lt;/li&gt;
&lt;li&gt;optionally — the original text itself (the &lt;code&gt;_source&lt;/code&gt; field does not affect the graph, but increases the size of the index)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;id: "doc1-chunk1"
knn_vector: [0.12, -0.33, ...] // number set by dimension parameter
metadata: {doc_id: "doc1", chunk: 1, text: "some text"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RAG, AWS Bedrock Knowledge Base, data, and vector creation&lt;/p&gt;

&lt;p&gt;The RAG process itself is well described in this diagram (see &lt;a href="https://aws.amazon.com/blogs/machine-learning/implementing-knowledge-bases-for-amazon-bedrock-in-support-of-gdpr-right-to-be-forgotten-requests/" rel="noopener noreferrer"&gt;Implementing Amazon Bedrock Knowledge Bases in support of GDPR (right to be forgotten) requests&lt;/a&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4flfzuhqj7kl38vroewv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4flfzuhqj7kl38vroewv.png" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is an overview of how RAG works and the role of the vector database in it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The client (e.g., a mobile app) sends a request to our Backend API, which runs on Kubernetes.&lt;/li&gt;
&lt;li&gt;The Backend API receives it and generates a &lt;a href="https://docs.aws.amazon.com/bedrock/latest/APIReference/API_agent-runtime_RetrieveAndGenerate.html" rel="noopener noreferrer"&gt;&lt;code&gt;RetrieveAndGenerate&lt;/code&gt;&lt;/a&gt; request to Bedrock, which transmits the Knowledge Base ID and the text of the client's request&lt;/li&gt;
&lt;li&gt;Bedrock launches the RAG pipeline, in which:&lt;/li&gt;
&lt;li&gt;it sends a request to the embedding model to convert it into a vector (s)&lt;/li&gt;
&lt;li&gt;performs a k-NN search in the OpenSearch index to find the most relevant data&lt;/li&gt;
&lt;li&gt;forms an extended prompt that contains the original request + the data returned by OpenSearch&lt;/li&gt;
&lt;li&gt;calls the GenAI model, to which it passes this extended prompt&lt;/li&gt;
&lt;li&gt;receives a response from it&lt;/li&gt;
&lt;li&gt;returns it in JSON format to our Backend API&lt;/li&gt;
&lt;li&gt;The Backend API sends the result to the client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How the process of converting text to vectors looks like in AWS Bedrock Knowledge Base:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We have some source — for example, a txt file in S3&lt;/li&gt;
&lt;li&gt;Bedrock reads it, and if it is large, it divides it into chunks with a size specified in the Bedrock parameters&lt;/li&gt;
&lt;li&gt;Bedrock sends each chunk of text to the embedding LLM model, which converts this chunk into a vector of fixed length (dimension) and returns it to the Bedrock pipeline&lt;/li&gt;
&lt;li&gt;Bedrock sends this vector along with metadata to the AWS OpenSearch vector store, where it is indexed for k-NN search&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Number of vectors&lt;/p&gt;

&lt;p&gt;The number of vectors in the index primarily depends on the data corpus (the size of all the input data we are working with) and how many chunks they will be divided into.&lt;/p&gt;

&lt;p&gt;What you need to understand: vectors are not created for individual tokens, but for parts of text, for whole phrases.&lt;/p&gt;

&lt;p&gt;Each embedding model has a limit on the number of tokens it can process at a time (the maximum “input length”).&lt;/p&gt;

&lt;p&gt;If the text is long, it is broken down into chunks, and a separate vector is created for each chunk.&lt;/p&gt;

&lt;p&gt;If we take, for example, an embedding model with a limit of 512 tokens and a dimension (dimension, d) of 1024 numbers, then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the phrase “&lt;em&gt;hello, world”&lt;/em&gt; fits into one “window” for embedding, and 1 vector will be created&lt;/li&gt;
&lt;li&gt;a 300-word paragraph of English text will yield approximately 400 tokens — this also fits into the window, and 1 embedding vector will also be created&lt;/li&gt;
&lt;li&gt;an article of 1,000 words will give approximately 1,300–1,400 tokens, so it will be divided into three chunks, and separate vectors will be created for them:&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chunk_1 =&amp;gt; [vector_1 with 1024 numbers]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chunk_2 =&amp;gt; [vector_2 with 1024 numbers]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;chunk_3 =&amp;gt; [vector_3 with 1024 numbers]&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;d&lt;/code&gt; (&lt;em&gt;dimension&lt;/em&gt;): is set by the embedding model, which converts data into vectors for storage in the vector store. For example, in Amazon Titan Embeddings, dimension=1024. This same parameter is specified when creating an index.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;m&lt;/code&gt; (&lt;em&gt;Maximum number of bi-directional links&lt;/em&gt;): the number of links between each node in the graph, this is a parameter of the HNSW graph, specified when we create an index, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"bedrock-knowledge-base-default-vector": {
  "type": "knn_vector",
  "dimension": 1024,
  "method": {
    "name": "hnsw",
    "engine": "faiss",
    "parameters": {
      "m": 16,
      "ef_construction": 512
    },
    "space_type": "l2"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, knowing all this data, we can calculate how much memory will be needed to build the graph in memory, for example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a number of vectors: 1,000,000&lt;/li&gt;
&lt;li&gt;&lt;code&gt;d=1024&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;m=16&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;num_vectors * 1.1 * (4 * d + 8 * m)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;1.1&lt;/code&gt;: 10% reserve is added for HNSW service structures&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;4&lt;/code&gt;: each coordinate (number in the vector) is stored as float32 = 4 bytes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;8&lt;/code&gt;: number of bytes for storing the id of each "neighbor" (64-bit int) (the number of which is given by &lt;code&gt;m&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, let’s calculate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.000.000 * 1.1 * (4*1024 + 8*16)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;4646400000.0 bytes, or 4.64 gigabytes, is the volume for the HNSW graph across all vectors (excluding replicas and shards, which will be discussed later).&lt;/p&gt;

&lt;p&gt;Now let’s consider the distribution into chunks and data nodes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if we have a total index of 100 gigabytes&lt;/li&gt;
&lt;li&gt;divided into 3 primary shards, and for each primary we have 1 replica shard — a total of 6 shards&lt;/li&gt;
&lt;li&gt;we have 3 data nodes — each node will have 2 shards&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A separate graph will be built for each shard, so we multiply 4.64 gigabytes by 2.&lt;/p&gt;

&lt;p&gt;But since the index is distributed across 3 nodes, we divide the result by 3.&lt;/p&gt;

&lt;p&gt;So the calculation will be as follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;graph_total&lt;/code&gt;: our 4.64 gigabytes, the total volume for the graph&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;graph_cluster&lt;/code&gt;: &lt;code&gt;graph_total&lt;/code&gt; * (1 + replicas) (primary + all replicas)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;graph_per_node&lt;/code&gt; = &lt;code&gt;graph_cluster&lt;/code&gt; / number of data nodes in the cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The formula will be as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph_total * (1 + replicas) / num_data_nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Having 1 primary shard + 1 replica shard, we get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4.64 gigabytes * 2 / 3 data nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;~ 3.1 GiB of memory per node purely for graphs.&lt;/p&gt;

&lt;p&gt;k-NN graphs are stored in off-heap memory, so we can already estimate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;8 (preferably 16) gigabytes under JVM Heap for OpenSearch itself&lt;/li&gt;
&lt;li&gt;3 GiB under graphs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The limit for k-NN graphs is set in &lt;code&gt;knn.memory.circuit_breaker.limit&lt;/code&gt;, and is usually 50: off-heap memory - see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/knn.html#knn-settings" rel="noopener noreferrer"&gt;k-NN differences, tuning, and limitations&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The metric in CloudWatch is &lt;code&gt;KNNGraphMemoryUsage&lt;/code&gt;, see &lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-cloudwatchmetrics.html#managedomains-cloudwatchmetrics-knn" rel="noopener noreferrer"&gt;k-NN metrics&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Or in the OpenSearch API itself —  &lt;code&gt;_plugins/_knn/stats&lt;/code&gt; and &lt;code&gt;_nodes/stats/indices,os,break&lt;/code&gt; (see &lt;a href="https://docs.opensearch.org/latest/api-reference/nodes-apis/nodes-stats/" rel="noopener noreferrer"&gt;Nodes Stats API&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;And to this we must add the OS page cache for “hot” data — vectors/metadata/text that are mapped from disk to memory for quick access — as we calculated for the index with logs.&lt;/p&gt;

&lt;p&gt;For the OS page cache, we can add another 20–50% of the total index size on the node, although this depends on the operations that will be performed. Ideally, if money is no object, you can add another 100% of the index size * 2 (for each replica of each shard) / number of nodes.&lt;/p&gt;

&lt;p&gt;So, if we take 1,000,000 vectors in the database, and the database itself is 30 gigabytes, 3 primary shards and 1 replica for each, and 3 data nodes, we get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;8 (preferably 16) gigabytes under JVM Heap for OpenSearch itself&lt;/li&gt;
&lt;li&gt;3 GB for graphs&lt;/li&gt;
&lt;li&gt;30 * 2 / 3 * 0.5 (50% for OS page cache) == 10 GB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And add another 10–15% for the operating system itself, and we get (16 + 3 + 10) * 1.15 == ~34 GB RAM.&lt;/p&gt;

&lt;p&gt;Read more on this topic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/sizing-domains.html" rel="noopener noreferrer"&gt;Sizing Amazon OpenSearch Service domains&lt;/a&gt;: general documentation from AWS&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.opensearch.org/2.6/search-plugins/knn/knn-index/#supported-faiss-methods" rel="noopener noreferrer"&gt;k-NN Index&lt;/a&gt;: OpenSearch documentation on index parameters&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://aws.amazon.com/blogs/big-data/choose-the-k-nn-algorithm-for-your-billion-scale-use-case-with-opensearch/" rel="noopener noreferrer"&gt;Choose the k-NN algorithm for your billion-scale use case with OpenSearch&lt;/a&gt;: algorithms and memory calculation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Well, that’s probably all for now.&lt;/p&gt;

&lt;p&gt;In the next posts (which I hope to write), we will set up a cluster, perhaps directly with Terraform, create an index, look at authentication and access to the OpenSearch Dashboard (because it’s a little out of place), and think about monitoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Useful links
&lt;/h3&gt;

&lt;p&gt;Elsatissearch/OpenSearch general docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.giffgaff.io/tech/elasticsearch-index-management" rel="noopener noreferrer"&gt;Elasticsearch index management&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codingexplained.com/coding/elasticsearch/introduction-elasticsearch-architecture" rel="noopener noreferrer"&gt;Introduction to the Elasticsearch Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://codingexplained.com/coding/elasticsearch/understanding-sharding-in-elasticsearch" rel="noopener noreferrer"&gt;Understanding Sharding in Elasticsearch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dattell.com/data-architecture-blog/elasticsearch-shards-definitions-sizes-optimizations-and-more/" rel="noopener noreferrer"&gt;Elasticsearch Shard Optimization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://opensearch.org/blog/optimize-opensearch-index-shard-size/" rel="noopener noreferrer"&gt;Optimize OpenSearch index shard sizes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medium.com/pcg-dach/how-we-accelerate-financial-and-operational-efficiency-with-amazon-opensearch-6b86b41d50a0" rel="noopener noreferrer"&gt;Reducing Amazon OpenSearch Service Costs: our Journey to over 60% Savings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managing-indices.html" rel="noopener noreferrer"&gt;Managing indexes in Amazon OpenSearch Service&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gist.github.com/rupeshtiwari/ef155a1e7fdf8157430cacaa18a7e79a" rel="noopener noreferrer"&gt;OpenSearch Performance&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenSearch as a vector store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/blogs/big-data/cost-optimized-vector-database-introduction-to-amazon-opensearch-service-quantization-techniques/" rel="noopener noreferrer"&gt;Cost Optimized Vector Database: Introduction to Amazon OpenSearch Service quantization techniques&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/opensearch-service/latest/developerguide/knn.html" rel="noopener noreferrer"&gt;k-Nearest Neighbor (k-NN) search in Amazon OpenSearch Service&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/aws-introduction-to-the-opensearch-service-as-a-vector-store/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>aws</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>VictoriaLogs: the "rate limit exceeded" error and monitoring ingested logs</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Sat, 13 Sep 2025 13:19:18 +0000</pubDate>
      <link>https://forem.com/setevoy/victorialogs-the-rate-limit-exceeded-error-and-monitoring-ingested-logs-18jl</link>
      <guid>https://forem.com/setevoy/victorialogs-the-rate-limit-exceeded-error-and-monitoring-ingested-logs-18jl</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi16jeyi716e5yd9w4cxn.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi16jeyi716e5yd9w4cxn.jpeg" width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We use two systems for collecting logs in the project: Grafana Loki and VictoriaLogs, to which Promtail simultaneously writes all collected logs.&lt;/p&gt;

&lt;p&gt;We cannot get rid of Loki: although developers have long since switched to VictoriaLogs, some alerts are still created from metrics generated by Loki, so it is still present in the system.&lt;/p&gt;

&lt;p&gt;And at some point, we started having two problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the disk on VictoriaLogs was getting clogged up — we had to reduce retention and increase the disk size, even though it had been sufficient before&lt;/li&gt;
&lt;li&gt;Loki started dropping logs with the error “ &lt;strong&gt;&lt;em&gt;Ingestion rate limit exceeded&lt;/em&gt;&lt;/strong&gt; ”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s dig in — what exactly is clogging up all the logs, why, and how can we monitor this?&lt;/p&gt;

&lt;h3&gt;
  
  
  Contents
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The issue: Loki — Ingestion rate limit exceeded&lt;/li&gt;
&lt;li&gt;Checking logs ingestion&lt;/li&gt;
&lt;li&gt;Records per second&lt;/li&gt;
&lt;li&gt;Bytes per second&lt;/li&gt;
&lt;li&gt;The cause&lt;/li&gt;
&lt;li&gt;Monitoring logs for the future&lt;/li&gt;
&lt;li&gt;Loki metrics&lt;/li&gt;
&lt;li&gt;VictoriaLogs metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The issue: Loki — Ingestion rate limit exceeded
&lt;/h3&gt;

&lt;p&gt;I started digging with the “ &lt;strong&gt;&lt;em&gt;Ingestion rate limit exceeded&lt;/em&gt;&lt;/strong&gt; ” error in Loki, because the disk space on VictoriaLogs was full for the same reason — too many logs were being written.&lt;/p&gt;

&lt;p&gt;In the alerts for Loki, it looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5dijz57k8iljzwmxd8zu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5dijz57k8iljzwmxd8zu.png" width="384" height="248"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The alert itself is generated from the metric &lt;a href="https://gitee.com/danasmile/Grafana-Loki/blob/master/docs/operations/observability.md" rel="noopener noreferrer"&gt;&lt;code&gt;loki_discarded_samples_total&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: Loki Logs Dropped
        expr: sum by (cluster, job, reason) (increase(loki_discarded_samples_total[5m])) &amp;gt; 0
        for: 1s
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn’t have an alert for VictoriaLogs, but it has a similar metric  — &lt;code&gt; vl_rows_dropped_total&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When Loki started dropping logs received from Promtail, I began checking Loki’s own logs, where I found errors with the rate limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
path=write msg="write operation failed" details="Ingestion rate limit exceeded for user fake (limit: 4194304 bytes/sec) while attempting to ingest '141' lines totaling '1040783' bytes, reduce log volume or contact your Loki administrator to see if the limit can be increased" org_id=fake
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I didn’t bother digging deeper, but simply increased the limit via &lt;a href="https://grafana.com/docs/loki/latest/configure/#limits_config" rel="noopener noreferrer"&gt;&lt;code&gt;limits_config&lt;/code&gt;&lt;/a&gt;, see &lt;a href="https://grafana.com/docs/loki/latest/operations/request-validation-rate-limits/#rate-limit-errors:" rel="noopener noreferrer"&gt;Rate-Limit Errors&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
    limits_config:
      ...
      ingestion_rate_mb: 8
      ingestion_burst_size_mb: 16
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For VictoriaLogs, I simply increased the disk size — &lt;a href="https://rtfm.co.ua/en/kubernetes-pvc-v-statefulset-and-the-forbidden-updates-to-statefulset-spec-error/" rel="noopener noreferrer"&gt;Kubernetes: PVC in StatefulSet, and the “Forbidden updates to StatefulSet spec” error&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This seemed to help for a while, but then the errors reappeared, so I had to investigate further.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking logs ingestion
&lt;/h3&gt;

&lt;p&gt;So, what we need to do is determine who is writing so many logs.&lt;/p&gt;

&lt;p&gt;We are interested in two parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;number of records per second&lt;/li&gt;
&lt;li&gt;number of bytes per second&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And we want to see this broken down by service.&lt;/p&gt;

&lt;h3&gt;
  
  
  Records per second
&lt;/h3&gt;

&lt;p&gt;You can get the log rate per second in VictoriaLogs simply with the &lt;a href="https://docs.victoriametrics.com/victorialogs/logsql/#rate-stats" rel="noopener noreferrer"&gt;&lt;code&gt;rate()&lt;/code&gt;&lt;/a&gt; function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{app=~".*"}
| stats by (app) rate() records_per_second
| sort by (records_per_second) desc 
| limit 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;we group on the label &lt;code&gt;app&lt;/code&gt; (&lt;code&gt;sum by (app)&lt;/code&gt; in Loki)&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;rate()&lt;/code&gt; we get the per-second rate of new records on the group &lt;code&gt;app&lt;/code&gt;, store the result in a new field &lt;code&gt;records_per_second&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;sort by &lt;code&gt;records_per_second&lt;/code&gt; in descending&lt;/li&gt;
&lt;li&gt;order and output the top 10 with &lt;a href="https://docs.victoriametrics.com/victorialogs/logsql/#rate-stats" rel="noopener noreferrer"&gt;&lt;code&gt;limit&lt;/code&gt;&lt;/a&gt; (or &lt;code&gt;head&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi1q9aohszatqz9ep4tp3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi1q9aohszatqz9ep4tp3.png" width="697" height="625"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Well, actually…&lt;/p&gt;

&lt;p&gt;We can see that VictoriaLogs is way ahead of the rest :-)&lt;/p&gt;

&lt;p&gt;In addition, the VictoriaLogs graph shows that most logs come from the Namespace &lt;code&gt;ops-monitoring-ns&lt;/code&gt;, where VictoriaLogs lives:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxt7pjp97evz66dkfffg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcxt7pjp97evz66dkfffg.png" width="800" height="190"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Loki, you can view the per-second rate with a similar function, &lt;code&gt;rate()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;topk(10, sum by (app) (rate({app=~".+"}[1m])))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1qrzg2nbfsoyojcukmsp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1qrzg2nbfsoyojcukmsp.png" width="800" height="682"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Bytes per second
&lt;/h3&gt;

&lt;p&gt;The same pattern can be seen with bytes per second.&lt;/p&gt;

&lt;p&gt;In Loki, we can see this simply with &lt;a href="https://grafana.com/docs/loki/latest/query/metric_queries/#log-range-aggregations" rel="noopener noreferrer"&gt;&lt;code&gt;bytes_over_time()&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;topk(10, sum by (app) (bytes_over_time({app=~".+"}[1m])))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd6iteiwneu9vkq7d3h81.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fd6iteiwneu9vkq7d3h81.png" width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For VictoriaLogs, there is &lt;a href="https://docs.victoriametrics.com/victorialogs/logsql/#rate-stats" rel="noopener noreferrer"&gt;&lt;code&gt;block_stats&lt;/code&gt;&lt;/a&gt;, but out of the box, it does not allow you to display statistics for each stream, see &lt;a href="https://docs.victoriametrics.com/victorialogs/faq/#how-to-determine-which-log-fields-occupy-the-most-of-disk-space" rel="noopener noreferrer"&gt;How to determine which log fields occupy the most disk space?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;However, there is &lt;code&gt;sum_len()&lt;/code&gt;, where we can get statistics, for example, like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* 
| stats by (app) sum_len() as bytes_used 
| sort (bytes_used) desc
| limit 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1k0x1s6mx8lydgzzdxcb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1k0x1s6mx8lydgzzdxcb.png" width="688" height="820"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Or per-second rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* 
| stats by (app) sum_len() as rows_len
| stats by (app) rate_sum(rows_len) as bytes_used_rate
| sort (bytes_used_rate) desc
| limit 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjw6w4a4o2c103plzya27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjw6w4a4o2c103plzya27.png" width="688" height="820"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The cause
&lt;/h3&gt;

&lt;p&gt;Everything turned out to be quite simple.&lt;/p&gt;

&lt;p&gt;All we had to do was look at the VictoriaLogs logs and see that it logs all entries received from Promtail — “ &lt;strong&gt;&lt;em&gt;new log entry&lt;/em&gt;&lt;/strong&gt; ”:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhhdruf2y9tcbhf0tf9uc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhhdruf2y9tcbhf0tf9uc.png" width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s check out the options for VictoriaLogs in the documentation &lt;a href="https://docs.victoriametrics.com/victorialogs/#list-of-command-line-flags" rel="noopener noreferrer"&gt;List of command-line flags&lt;/a&gt;, where I found the &lt;code&gt;-logIngestedRows&lt;/code&gt; parameter:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;-logIngestedRows&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Whether to log all the ingested log entries; this can be useful for debugging of data ingestion; see&lt;/em&gt; &lt;a href="https://docs.victoriametrics.com/victorialogs/data-ingestion/" rel="noopener noreferrer"&gt;&lt;em&gt;https://docs.victoriametrics.com/victorialogs/data-ingestion/&lt;/em&gt;&lt;/a&gt;_ ; see also -logNewStreams_&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The default value is not specified here, and I initially thought that it was simply set to “true,” so I went to the values of our chart to set it to “false,” where I saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
victoria-logs-single:
  server:
    ...
    extraArgs:
      logIngestedRows: "true"
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ouch…&lt;/p&gt;

&lt;p&gt;I enabled this logging for some reason once and forgot about it.&lt;/p&gt;

&lt;p&gt;Actually, we can switch it to “false” (or just delete it, because it’s “false” by default), deploy it, and the problem is solved.&lt;/p&gt;

&lt;p&gt;At the same time, we can switch &lt;code&gt;loggerLevel&lt;/code&gt;, which is set to &lt;code&gt;INFO&lt;/code&gt; by default.&lt;/p&gt;

&lt;p&gt;And here, by the way, there could be an interesting picture: if both Loki and VictoriaLogs wrote a log about every log record they received, then…&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Loki receives any record from Promtail&lt;/li&gt;
&lt;li&gt;writes this event to its own log&lt;/li&gt;
&lt;li&gt;Promtail sees a new record from the container with Loki and again transmits it to both Loki and VictoriaLogs&lt;/li&gt;
&lt;li&gt;VictoriaLogs records in its log that it has received this record&lt;/li&gt;
&lt;li&gt;Promtail sees a new record from the container with VictoriaLogs and transmits it to both Loki and VictoriaLogs&lt;/li&gt;
&lt;li&gt;Loki receives this record from Promtail&lt;/li&gt;
&lt;li&gt;records this event in its own log&lt;/li&gt;
&lt;li&gt;…&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A kind of “fork logs bomb”.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitoring logs for the future
&lt;/h3&gt;

&lt;p&gt;Here, too, everything is simple: either we use the default metrics from Loki and VictoriaLogs, or we generate our own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loki metrics
&lt;/h3&gt;

&lt;p&gt;In the &lt;a href="https://artifacthub.io/packages/helm/grafana/loki-simple-scalable" rel="noopener noreferrer"&gt;Loki chart&lt;/a&gt;, there is an option called &lt;code&gt;monitoring.serviceMonitor.enabled&lt;/code&gt;. You can simply enable it, and then VictoriaMetrics Operator will create &lt;a href="https://rtfm.co.ua/victoriametrics-stvorennya-kubernetes-monitoring-stack-z-vlasnim-helm-chartom/#VMServiceScrape" rel="noopener noreferrer"&gt;VMServiceScrape&lt;/a&gt; and start collecting metrics.&lt;/p&gt;

&lt;p&gt;The following may be of interest for Loki:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;loki_log_messages_total&lt;/code&gt;: Total number of messages logged by Loki&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;loki_distributor_bytes_received_total&lt;/code&gt;: The total number of uncompressed bytes received per tenant&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;loki_distributor_lines_received_total&lt;/code&gt;: The total number of lines received per tenant&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;loki_discarded_samples_total&lt;/code&gt;: The total number of samples that were dropped&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;loki_discarded_bytes_total&lt;/code&gt;: The total number of bytes that were dropped&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Or we can create our own metrics with information for each &lt;code&gt;app&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kind: ConfigMap
apiVersion: v1
metadata:
  name: loki-recording-rules
data:
  rules.yaml: |-
  ...
      - name: Loki-Logs-Stats

        rules:

        - record: loki:logs:ingested_rows:sum:rate:5m
          expr: |
            topk(10, 
              sum by (app) (
                rate({app=~".+"}[5m])
              )
            )

        - record: loki:logs:ingested_bytes:sum:rate:5m
          expr: |
            topk(10, 
              sum by (app) (
                bytes_rate({app=~".+"}[5m])
              )
            )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy and check:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1z9ty2q67eqi6xfhsnwx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1z9ty2q67eqi6xfhsnwx.png" width="800" height="228"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And then use these metrics to create alerts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: Loki Logs Ingested Rows Too High
        expr: sum by (app) (loki:logs:ingested_rows:sum:rate:5m) &amp;gt; 100
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
        annotations:
          summary: 'Loki Logs Ingested Rows Too High'
          description: |-
            Grafana Loki ingested too many log rows
            *App*: `{{ "{{" }} $labels.app }}`
            *Value*: `{{ "{{" }} $value | humanize }}` records per second
          tags: devops

      - alert: Loki Logs Ingested Bytes Too High
        expr: sum by (app) (loki:logs:ingested_bytes:sum:rate:5m) &amp;gt; 50000
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
        annotations:
          summary: 'Loki Logs Ingested Bytes Too High'
          description: |-
            Grafana Loki ingested too many log bytes
            *App*: `{{ "{{" }} $labels.app }}`
            *Value*: `{{ "{{" }} $value | humanize1024 }}` bytes per second
          tags: devops
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  VictoriaLogs metrics
&lt;/h3&gt;

&lt;p&gt;Add metrics collection from VictoriaLogs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
victoria-logs-single:
  server:
    ...
    vmServiceScrape:
      enabled: true
..
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;vl_bytes_ingested_total&lt;/code&gt;: Total estimate of the volume of log bytes accepted by injectors, broken down by protocol using tags&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vl_rows_ingested_total&lt;/code&gt;: Total number of log records successfully accepted by injectors, broken down by injection protocols using tags in the raw series&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vl_rows_dropped_total&lt;/code&gt;: The total number of rows dropped by the server during injection, with the reasons marked (e.g., debug mode, too many fields, timestamps out of bounds)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vl_too_long_lines_skipped_total&lt;/code&gt;: Number of rows exceeding the size skipped due to exceeding the configured maximum row size&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vl_free_disk_space_bytes&lt;/code&gt;: Current free space available on the file system hosting the storage path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All metrics can be found in the documentation: &lt;a href="https://docs.victoriametrics.com/victorialogs/metrics/" rel="noopener noreferrer"&gt;Metrics of VictoriaLogs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And add an alert like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: VictoriaLogs Logs Dropped Rows Too High
        expr: sum by (reason) (vl_rows_dropped_total) &amp;gt; 0
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
        annotations:
          summary: 'VictoriaLogs Logs Dropped Rows Too High'
          description: |-
            VictoriaLogs dropped too many log rows
            *Reason*: `{{ "{{" }} $labels.app }}`
            *Value*: `{{ "{{" }} $value | humanize }}` records dropped
          tags: devops
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But again, &lt;code&gt;vl_rows_ingested_total&lt;/code&gt; won't tell us which app is writing too many logs.&lt;/p&gt;

&lt;p&gt;Therefore, we can add RecordingRules, see &lt;a href="https://rtfm.co.ua/en/victorialogs-creating-recording-rules-with-vmalert/" rel="noopener noreferrer"&gt;VictoriaLogs: creating Recording Rules with VMAlert&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apiVersion: operator.victoriametrics.com/v1beta1
kind: VMRule
metadata:
  name: vmlogs-alert-rules
spec:

  groups:

    - name: VM-Logs-Ingested
      # an expressions for the VictoriaLogs datasource
      type: vlogs
      rules:
        - record: vmlogs:logs:ingested_rows:stats:rate
          expr: |
            {app=~".*"} 
            | stats by (app) rate() records_per_second 
            | sort by (records_per_second) desc
            | limit 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy, check again:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fziooapcty034reaslgx1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fziooapcty034reaslgx1.png" width="800" height="191"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And add an alert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
      - alert: VictoriaLogs Logs Ingested Rows Too High
        expr: sum by (app) (vmlogs:logs:ingested_rows:stats:rate) &amp;gt; 100
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
        annotations:
          summary: 'VictoriaLogs Logs Ingested Rows Too High'
          description: |-
            Grafana Loki ingested too many log rows
            *App*: `{{ "{{" }} $labels.app }}`
            *Value*: `{{ "{{" }} $value | humanize }}` records per second
          tags: devops
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmt4g2wlpugpd76f94x57.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmt4g2wlpugpd76f94x57.png" width="489" height="153"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Well, that’s all for now.&lt;/p&gt;

&lt;p&gt;This is basic monitoring for VictoriaLogs and Loki.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/victorialogs-rate-limit-exceeded-and-monitoring-ingested-logs/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>monitoring</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>VictoriaMetrics: migrating VMSingle and VictoriaLogs data between Kubernetes clusters</title>
      <dc:creator>Arseny Zinchenko</dc:creator>
      <pubDate>Wed, 23 Jul 2025 11:00:00 +0000</pubDate>
      <link>https://forem.com/setevoy/victoriametrics-migrating-vmsingle-and-victorialogs-data-between-kubernetes-clusters-559f</link>
      <guid>https://forem.com/setevoy/victoriametrics-migrating-vmsingle-and-victorialogs-data-between-kubernetes-clusters-559f</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi16jeyi716e5yd9w4cxn.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi16jeyi716e5yd9w4cxn.jpeg" width="800" height="298"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We have VictoriaMetrics and VictoriaLogs running on an AWS Elastic Kubernetes Service cluster.&lt;/p&gt;

&lt;p&gt;We do major upgrades to EKS by creating a new cluster, and therefore, we have to transfer monitoring data from the old VMSingle instance to the new one.&lt;/p&gt;

&lt;p&gt;For VictoriaMetrics, there is the &lt;a href="https://docs.victoriametrics.com/victoriametrics/vmctl/" rel="noopener noreferrer"&gt;&lt;code&gt;vmctl&lt;/code&gt;&lt;/a&gt; tool which can migrate data through the APIs of the old and new instances, acting as a proxy between the two instances.&lt;/p&gt;

&lt;p&gt;With VictoriaLogs, the situation is still a bit more complicated and there are currently two options — let’s look at them further.&lt;/p&gt;

&lt;p&gt;So, here’s our setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;old Kubernetes cluster EKS 1.30&lt;/li&gt;
&lt;li&gt;new Kubernetes cluster EKS 1.33&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;VictoriaMetrics and VictoriaLogs are deployed with our own Helm-chart which installs &lt;a href="https://github.com/VictoriaMetrics/helm-charts/tree/master/charts/victoria-metrics-k8s-stack" rel="noopener noreferrer"&gt;victoria-metrics-k8s-stack&lt;/a&gt; and &lt;a href="https://github.com/VictoriaMetrics/helm-charts/tree/master/charts/victoria-logs-single" rel="noopener noreferrer"&gt;victoria-logs-single&lt;/a&gt; through dependencies, plus a set of various additional services such as PostgreSQL Exporter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migrating VictoriaMetrics metrics
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Running &lt;code&gt;vmctl&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;vmctl&lt;/code&gt; supports migration from VMSinlge to VMClutser and vice versa, or simply between VMSinlge =&amp;gt; VMSinlge or VMClutser =&amp;gt; VMClutser instances.&lt;/p&gt;

&lt;p&gt;In our case, these are just two instances of VMSingle.&lt;/p&gt;

&lt;p&gt;You can install &lt;code&gt;vmctl&lt;/code&gt; locally in a Pod with VMSingle, see &lt;a href="https://docs.victoriametrics.com/vmctl/#how-to-build" rel="noopener noreferrer"&gt;How to build&lt;/a&gt;, but since the CLI still works through the API, it is easier to create a separate Pod and do everything from it. The Docker image is available here - &lt;a href="https://hub.docker.com/r/victoriametrics/vmctl" rel="noopener noreferrer"&gt;victoriametrics/vmctl&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Since the &lt;code&gt;entrypoint&lt;/code&gt; for this image is set in &lt;code&gt;/vmctl-prod&lt;/code&gt;, to enter to the container we can pass &lt;code&gt;--command&lt;/code&gt;, run &lt;code&gt;ping&lt;/code&gt; and &lt;code&gt;sleep&lt;/code&gt; in a loop, and then do everything we need from the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kubectl run vmctl-pod --image=victoriametrics/vmctl --restart=Never --command -- /bin/sh -c "while true; echo ping; do sleep 5; done"
pod/vmctl-pod created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no difference on which cluster to run it on.&lt;/p&gt;

&lt;p&gt;Connect to the Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk exec -ti vmctl-pod -- sh 
/ #.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/ # /vmctl-prod vm-native --help
NAME:
   vmctl vm-native - Migrate time series between VictoriaMetrics installations via native binary format

USAGE:
   vmctl vm-native [command options] [arguments...]

OPTIONS:
   -s Whether to run in silent mode. If set to true no confirmation prompts will appear. (default: false)
   ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Start the migration
&lt;/h3&gt;

&lt;p&gt;The Kubernetes Pod with &lt;code&gt;vmctl&lt;/code&gt; will act as a proxy between the source and destination, so it must have a stable network.&lt;/p&gt;

&lt;p&gt;In addition, if you are migrating a large amount of data, then look towards the &lt;code&gt;--vm-concurrency&lt;/code&gt; option to run the migration in several parallel threads, but keep in mind that each worker will use additional CPU and Memory.&lt;/p&gt;

&lt;p&gt;The documentation also describes possible issues with limits — see &lt;a href="https://docs.victoriametrics.com/victoriametrics/vmctl/#migrating-data-from-victoriametrics" rel="noopener noreferrer"&gt;Migrating data from VictoriaMetrics&lt;/a&gt;, and it is useful to look at the &lt;a href="https://docs.victoriametrics.com/victoriametrics/vmctl/#migration-tips" rel="noopener noreferrer"&gt;Migration tips&lt;/a&gt; section.&lt;/p&gt;

&lt;p&gt;It is also recommended to add the &lt;code&gt;--vm-native-filter-match='{__name__!~"vm_.*"}'&lt;/code&gt; filter to avoid migrating metrics that are related to VictoriaMetrics itself, as this can lead to data collision - duplicate time series.&lt;/p&gt;

&lt;p&gt;Although in my case, VMAgent adds a metric with the name of the cluster to all metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  vmagent:
    enabled: true
    spec:
      externalLabels:
        cluster: "eks-ops-1-33"
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;resources.limits&lt;/code&gt; are set for the VMSingle Pod, it's better to disable them or increase them, and increase the &lt;code&gt;resources.requests&lt;/code&gt;, because I was getting 504 and Pod Eviction few times.&lt;/p&gt;

&lt;p&gt;And maybe it makes sense to move VMSingle to a separate WorkerNode, because in our case, the t3 and Spot EC2 instances are used for monitoring.&lt;/p&gt;

&lt;p&gt;What and where we will migrate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;source&lt;/strong&gt; : VMSingle on EKS 1.30
&lt;/li&gt;
&lt;li&gt;endpoint: &lt;code&gt;vmsingle.monitoring.1-30.ops.example.co&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;destination&lt;/strong&gt; : VMSingle on EKS 1.33 endpoint
&lt;/li&gt;
&lt;li&gt;endpoint: &lt;code&gt;vmsingle.monitoring.1-33.ops.example.co&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the Pod with the &lt;code&gt;vmctl&lt;/code&gt;, check access to both endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/ # apk add curl

/ # curl -X GET -I [https://vmsingle.monitoring.1-30.ops.example.co](https://vmsingle.monitoring.1-30.ops.example.co)
HTTP/2 400

/ # curl -X GET -I [https://vmsingle.monitoring.1-33.ops.example.co](https://vmsingle.monitoring.1-33.ops.example.co)
HTTP/2 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And start the migration for the entire period — I don’t remember when exactly this cluster was created, let’s say January 2023:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/ # /vmctl-prod vm-native \
&amp;gt; --vm-native-src-addr=https://vmsingle.monitoring.1-30.ops.example.co/ \
&amp;gt; --vm-native-dst-addr=https://vmsingle.monitoring.1-33.ops.example.co \
&amp;gt; --vm-native-filter-match='{ __name__!~"vm_.*"}' \
&amp;gt; --vm-native-filter-time-start='2023-01-01'
VictoriaMetrics Native import mode
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The process has started:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4cen0lwqkoojyx9xt978.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4cen0lwqkoojyx9xt978.png" width="800" height="89"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The resources on the source — memory — went up to 5–6 gigabytes:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhbhde9xuuoyljezx60gi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhbhde9xuuoyljezx60gi.png" width="800" height="121"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The destination had a little more CPU, but less memory:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6z5f2zu6ls1evgi4548l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6z5f2zu6ls1evgi4548l.png" width="800" height="121"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And completion — took 6 hours, but I did it without &lt;code&gt;--vm-concurrency&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
2025/06/23 19:07:29 Import finished!
2025/06/23 19:07:29 VictoriaMetrics importer stats:
  time spent while importing: 6h30m8.537582366s;
  total bytes: 16.5 GB;
  bytes/s: 705.9 kB;
  requests: 6882;
  requests retries: 405;
2025/06/23 19:07:29 Total time: 6h30m8.541808518s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have a month’s worth of graphs on the new EKS cluster, even though the cluster was created just a week ago:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvbodol539scp8g5vqss7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvbodol539scp8g5vqss7.png" width="800" height="163"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  If migration fails
&lt;/h3&gt;

&lt;p&gt;First, check the request — you need to find the old metrics on the new cluster.&lt;/p&gt;

&lt;p&gt;In my case, I can check on the new cluster using the &lt;code&gt;cluster&lt;/code&gt; label - a useful thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s 'http://localhost:8429/prometheus/api/v1/series' -d 'match[]={cluster="eks-ops-1-30"}' | jq
...
    {
      " __name__": "yace_cloudwatch_targetgroupapi_requests_total",
      "cluster": "eks-ops-1-30",
      "job": "yace-exporter",
      "instance": "yace-service:5000",
      "prometheus": "ops-monitoring-ns/vm-k8s-stack"
    }
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Documentation on deleting metrics and working with the VictoriaMetrics API in general — &lt;a href="https://docs.victoriametrics.com/guides/guide-delete-or-replace-metrics/" rel="noopener noreferrer"&gt;How to delete or replace metrics in VictoriaMetrics&lt;/a&gt; and &lt;a href="https://docs.victoriametrics.com/victoriametrics/url-examples/#apiv1admintsdbdelete_series" rel="noopener noreferrer"&gt;Deletes time series from VictoriaMetrics&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Run a request to the &lt;code&gt;/api/v1/admin/tsdb/delete_series&lt;/code&gt; endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s 'http://localhost:8429/api/v1/admin/tsdb/delete_series' -d 'match[]={cluster="eks-ops-1-30"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s 'http://localhost:8429/prometheus/api/v1/series' -d 'match[]={cluster="eks-ops-1-30"}' | jq
{
  "status": "success",
  "data": []
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you can repeat the migration.&lt;/p&gt;

&lt;p&gt;Another option is to add the &lt;code&gt;dedup.minScrapeInterval=1ms&lt;/code&gt; option, then VictoriaMetrics will remove duplicates by itself, but I have not tested this option.&lt;/p&gt;

&lt;h3&gt;
  
  
  VictoriaLogs migration
&lt;/h3&gt;

&lt;p&gt;With VictoriaLogs, the situation is a little more complicated, because &lt;a href="https://docs.victoriametrics.com/victorialogs/querying/vlogscli/#" rel="noopener noreferrer"&gt;vlogscli&lt;/a&gt; does not yet (hopefully, they will add) have any option to transfer data like in &lt;code&gt;vmctl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And there is a problem here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;if there is no data in VictoriaLogs on the new cluster yet, you can simply copy the old data from &lt;code&gt;rsync&lt;/code&gt; to the PVC of the new VictoriaLogs instance similarly&lt;/li&gt;
&lt;li&gt;the same, if the new VMLogs instance already has some data, but with no overlapping days from the old instance, because the data in VictoriaLogs storage is stored in directories by day, and they can be safely transferred&lt;/li&gt;
&lt;li&gt;but if there is data and/or days are duplicated, then for now the only option is to run two VictoriaLogs instances: one with old data, one with new data, and have a &lt;code&gt;vlselect&lt;/code&gt; instance in front of them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When VictoriaLogs will add Object Storage support, it will be easier, and it is already in its &lt;a href="https://docs.victoriametrics.com/victorialogs/roadmap/index.html#" rel="noopener noreferrer"&gt;Roadmap&lt;/a&gt;. Then you can just keep everything in AWS S3, as we do it now with Grafana Loki.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: copying data with &lt;code&gt;rsync&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;So, the first option is if there is no data in the new VictoriaLogs instance, or there are no records on the same days on both instances — the old and the new.&lt;/p&gt;

&lt;p&gt;Here we can simply copy the data, and it will be available on the new Kubernetes cluster.&lt;/p&gt;

&lt;p&gt;See VictoriaLogs documentation — &lt;a href="https://docs.victoriametrics.com/victorialogs/#backup-and-restore" rel="noopener noreferrer"&gt;Backup and restore&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I did it with &lt;code&gt;rsync&lt;/code&gt;, but you can try it with utilities like &lt;a href="https://github.com/BeryJu/korb?tab=readme-ov-file#example-exporting-from-pvc-to-tar" rel="noopener noreferrer"&gt;&lt;code&gt;korb&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Let’s check where the logs are stored in the VictoriaLogs Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe pod atlas-victoriametrics-vmlogs-new-server-0
Name: atlas-victoriametrics-vmlogs-new-server-0
...
Containers:
  vlogs:
    ...
    Args:
      --storageDataPath=/storage
    ...
    Mounts:
      /storage from server-volume (rw)
    ...
Volumes:
  server-volume:
    Type: PersistentVolumeClaim (a reference to a PersistentVolumeClaim in the same namespace)
    ClaimName: server-volume-atlas-victoriametrics-vmlogs-new-server-0
    ReadOnly: false
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the contents of the &lt;code&gt;/storage&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~ $ ls -l /storage/partitions/
total 32
drwxrwsr-x 4 1000 2000 4096 Jun 16 00:00 20250616
drwxrwsr-x 4 1000 2000 4096 Jun 17 00:00 20250617
drwxrwsr-x 4 1000 2000 4096 Jun 18 00:00 20250618
drwxrwsr-x 4 1000 2000 4096 Jun 19 00:00 20250619
drwxrwsr-x 4 1000 2000 4096 Jun 20 00:00 20250620
drwxr-sr-x 4 1000 2000 4096 Jun 21 00:00 20250621
drwxr-sr-x 4 1000 2000 4096 Jun 22 00:00 20250622
drwxr-sr-x 4 1000 2000 4096 Jun 23 00:00 20250623
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But there’s no &lt;code&gt;rsync&lt;/code&gt; or SSH in the event itself, and we can't even install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~ $ rsync
sh: rsync: not found
~ $ apk add rsync
ERROR: Unable to lock database: Permission denied
ERROR: Failed to open apk database: Permission denied
~ $ su
su: must be suid to work properly
~ $ sudo -s
sh: sudo: not found
~ $ ssh
sh: ssh: not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So let’s just run &lt;code&gt;rsync&lt;/code&gt; from the old EC2 to the new one.&lt;/p&gt;

&lt;p&gt;How to find the right directory on the host — see in my &lt;a href="https://rtfm.co.ua/en/kubernetes-find-a-directory-with-a-mounted-volume-in-a-pod-on-its-host/" rel="noopener noreferrer"&gt;Kubernetes: find a directory with a mounted volume in a Pod on its host&lt;/a&gt; post.&lt;/p&gt;

&lt;p&gt;Setting up SSH access to EC2 for EKS — in the &lt;a href="https://rtfm.co.ua/en/aws-karpenter-and-ssh-for-kubernetes-workernodes/" rel="noopener noreferrer"&gt;AWS: Karpenter and SSH for Kubernetes WorkerNodes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Check Pod on the old cluster — find its EC2 and Container ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe pod atlas-victoriametrics-vmlogs-new-server-0 | grep 'Node\|Container'
Node: ip-10-0-39-190.ec2.internal/10.0.39.190
Containers:
    Container ID: containerd://db9fa73a4d37045b0338ae48438f9815e4f6f92c3fd6546604ca5d1338f19844
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect to the WorkerNode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ssh -i ~/.ssh/eks_ec2 ec2-user@10.0.39.190
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the &lt;code&gt;mounts[]&lt;/code&gt; find the directory for the &lt;code&gt;/storage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-39-190 ec2-user]# crictl inspect db9fa73a4d37045b0338ae48438f9815e4f6f92c3fd6546604ca5d1338f19844 | jq
...
    "mounts": [
      {
        "containerPath": "/storage",
        "gidMappings": [],
        "hostPath": "/var/lib/kubelet/pods/5192e1f9-20ea-49c6-99ed-775af5e44183/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount",
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check its content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-39-190 ec2-user]# ll /var/lib/kubelet/pods/5192e1f9-20ea-49c6-99ed-775af5e44183/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount
total 24
drwxrwsr-x 3 ec2-user 2000 4096 Nov 19 2024 cache
-rw-rw-r-- 1 ec2-user 2000 0 Jun 20 19:20 flock.lock
drwxrws--- 2 root 2000 16384 Sep 4 2024 lost+found
drwxrwsr-x 10 ec2-user 2000 4096 Jun 25 00:25 partitions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We only need the data from the &lt;code&gt;partitions&lt;/code&gt; directory here.&lt;/p&gt;

&lt;p&gt;Repeat for VictoriaLogs on a new cluster, though Amazon Linux 2023 does not have &lt;code&gt;critctl&lt;/code&gt; - however, it does have &lt;code&gt;ctr&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Check ContainerD Namespaces for containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-41-247 ec2-user]# ctr ns ls
NAME LABELS 
k8s.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the container with the &lt;code&gt;ctr containers info&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-41-247 ec2-user]# ctr -n k8s.io containers info 9fd6fefaec92ab76093651239f6e177686e7c7dd012d53d4bf2e6820260aa884
...
            {
                "destination": "/storage",
                "type": "bind",
                "source": "/var/lib/kubelet/pods/4b2f179d-9ada-403e-9680-b76e3507563f/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount",
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the content of the &lt;code&gt;/var/lib/kubelet/pods/4b2f179d-9ada-403e-9680-b76e3507563f/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-41-247 ec2-user]# ll /var/lib/kubelet/pods/4b2f179d-9ada-403e-9680-b76e3507563f/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount
total 20
-rw-rw-r--. 1 ec2-user 2000 0 Jun 25 12:18 flock.lock
drwxrws---. 2 root 2000 16384 Jun 10 09:41 lost+found
drwxrwsr-x. 10 ec2-user 2000 4096 Jun 25 00:32 partitions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pay attention to the user’s and group’s IDs, they must be the same — &lt;code&gt;ec2-user(1000)&lt;/code&gt; and group &lt;code&gt;2000&lt;/code&gt; on both EC2 instances in my case.&lt;/p&gt;

&lt;p&gt;Create an SSH key on the old cluster and check the connection to the EC2 of the new cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-39-190 ec2-user]# ssh -i .ssh/eks ec2-user@10.0.41.247
...
[ec2-user@ip-10-0-41-247 ~]$
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OK, we have it.&lt;/p&gt;

&lt;p&gt;Now install &lt;code&gt;rsync&lt;/code&gt; on both instances:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-39-190 ec2-user]# yum -y install rsync
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just in case, you can back up the data on a new instance — either with an EBS snapshot or with &lt;code&gt;tar&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more thing about the retention period, I’m glad I mentioned it — we have only 7 days. Therefore, if you copy the data now, the old logs will be deleted.&lt;/p&gt;

&lt;p&gt;Let’s change it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;... 
retentionPeriod: 30d 
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the new instance, let’s make a directory where we will transfer the data (but can be done directly to the PVC directory):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-41-247 ec2-user]# mkdir vmlogs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And from the old EC2 run &lt;code&gt;rsync&lt;/code&gt; to the new instance to the &lt;code&gt;$HOME/vmlogs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-39-190 ec2-user]# rsync -avz --progress --delete -e "ssh -i .ssh/eks" \
&amp;gt; /var/lib/kubelet/pods/5192e1f9-20ea-49c6-99ed-775af5e44183/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount/partitions/ \
&amp;gt; ec2-user@10.0.41.247:/home/ec2-user/vmlogs/
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-a&lt;/code&gt;: archive mode (saves permissions, create/modify time, and structure)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-v&lt;/code&gt;: verbose mode&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-z&lt;/code&gt;: compress data&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--progress&lt;/code&gt;: show progress&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--delete&lt;/code&gt;: delete data from destination if it is deleted in source&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-e&lt;/code&gt;: command with an ssh key&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first argument is the local directory, and the second is where to copy to.&lt;/p&gt;

&lt;p&gt;And for the source, specify "&lt;code&gt;/&lt;/code&gt;" at the end of &lt;code&gt;.../mount/partitions/&lt;/code&gt; - copy the contents, not the folder itself.&lt;/p&gt;

&lt;p&gt;If you get errors with permission denied, add &lt;code&gt;--rsync-path="sudo rsync"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The transfer is complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
sent 2,483,902,797 bytes received 189,037 bytes 20,614,869.99 bytes/sec
total size is 2,553,861,458 speedup is 1.03
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the data on the new instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-41-247 ec2-user]# ll vmlogs/
total 0
drwxrwsr-x. 4 ec2-user ec2-user 35 Jun 18 00:00 20250618
drwxrwsr-x. 4 ec2-user ec2-user 35 Jun 19 00:00 20250619
drwxrwsr-x. 4 ec2-user ec2-user 35 Jun 20 00:00 20250620
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 21 00:00 20250621
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 22 00:00 20250622
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 23 00:00 20250623
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 24 00:00 20250624
drwxr-sr-x. 4 ec2-user ec2-user 35 Jun 25 00:00 20250625
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this is where I encountered the problem of overlapping data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-41-247 ec2-user]# cp -r vmlogs/* /var/lib/kubelet/pods/84a4ecd3-21a0-4eec-bebc-078a5105bf86/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount/partitions/
cp: overwrite '/var/lib/kubelet/pods/84a4ecd3-21a0-4eec-bebc-078a5105bf86/volumes/kubernetes.io~csi/pvc-da384ead-50e8-425f-b3b0-47c35f3a5155/mount/partitions/20250618/datadb/parts.json'?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I asked the developers about the JSON merge option, but it will not work.&lt;/p&gt;

&lt;p&gt;If the data doesn’t overlap, then just copy the data and restart the VictoriaLogs Pod.&lt;/p&gt;

&lt;p&gt;In my case, I had to do it a little differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: run two VMLogs + &lt;code&gt;vlselect&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;So, if we have data for the same days on the old and new VictoriaLogs instances, we can do the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a second VMLogs instance on the new EKS cluster&lt;/li&gt;
&lt;li&gt;copy data from the old cluster to the PVC of the new VMLogs instance&lt;/li&gt;
&lt;li&gt;add a new Pod with &lt;code&gt;vlselect&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;for the &lt;code&gt;vlselect&lt;/code&gt; we specify two sources - both VMLogs instances&lt;/li&gt;
&lt;li&gt;and then for the Grafana VictoriaLogs data source we use the URL of the &lt;code&gt;vlselect&lt;/code&gt; service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We could just add &lt;code&gt;vlselect&lt;/code&gt; and route the requests to the old cluster - but I need to delete the old cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;vlselect&lt;/code&gt; vs VMLogs
&lt;/h3&gt;

&lt;p&gt;In fact, &lt;code&gt;vlselect&lt;/code&gt; is the same binary file as VictoriaLogs, which simplifies the whole setup for us - see the &lt;a href="https://docs.victoriametrics.com/victorialogs/cluster/" rel="noopener noreferrer"&gt;VictoriaLogs cluster&lt;/a&gt; documentation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Note that all the VictoriaLogs cluster components  —  _vlstorage,&lt;/em&gt; &lt;em&gt;vlinsert&lt;/em&gt;, and &lt;em&gt;vlselect - share the same executable - _victoria-logs-prod&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Therefore, we can simply take another &lt;a href="https://github.com/VictoriaMetrics/helm-charts/tree/master/charts/victoria-logs-single" rel="noopener noreferrer"&gt;victoria-logs-single&lt;/a&gt; Helm chart and run everything from it.&lt;/p&gt;

&lt;p&gt;And we’ll actually be building a kind of “minimal VictoriaLogs cluster”:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;our current VictoriaLogs instance will play the role of &lt;code&gt;vlinsert&lt;/code&gt; and &lt;code&gt;vlstorage&lt;/code&gt; - new logs of the new cluster are written there&lt;/li&gt;
&lt;li&gt;the new VictoriaLogs instance will play the role of &lt;code&gt;vlstorage&lt;/code&gt; - we will store data from the old cluster in it&lt;/li&gt;
&lt;li&gt;the third VictoriaLogs instance will play the role of &lt;code&gt;vlselect&lt;/code&gt; - it will be an endpoint for Grafana, and will make API requests to search for logs from both VictoriaLogs instances&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F73s0wmuncoyy3ynm86uj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F73s0wmuncoyy3ynm86uj.png" width="800" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Helm chart update
&lt;/h3&gt;

&lt;p&gt;I’m not ready to run the full version of the VictoriaLogs cluster yet, so let’s just add a couple more dependencies to our current Helm chart.&lt;/p&gt;

&lt;p&gt;Edit &lt;code&gt;Chart.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
dependencies:
...
- name: victoria-logs-single
  version: ~0.11.2
  repository: https://victoriametrics.github.io/helm-charts
  alias: vmlogs-new
- name: victoria-logs-single
  version: ~0.11.2
  repository: https://victoriametrics.github.io/helm-charts
  alias: vmlogs-old
- name: victoria-logs-single
  version: ~0.11.2
  repository: https://victoriametrics.github.io/helm-charts
  alias: vlselect
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, we deploy three charts (more precisely, one chart, just with different values, see &lt;a href="https://rtfm.co.ua/en/helm-multiple-deployment-of-the-same-chart-with-charts-dependency/" rel="noopener noreferrer"&gt;Helm: multiple deployments of a single chart with Chart’s dependency&lt;/a&gt;), and each one has its own &lt;code&gt;alias&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;vmlogs-new&lt;/code&gt;: the current VMLogs instance on the new EKS cluster&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vmlogs-old&lt;/code&gt;: a new instance to which we will transfer data from the old EKS cluster&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vlselect&lt;/code&gt;: will be our new endpoint for searching logs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only thing is that during deployment there may be an error due to the length of the pod names, because I initially set too long names in the &lt;code&gt;alias&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
Pod "atlas-victoriametrics-victoria-logs-single-old-server-0" is invalid: metadata.labels: Invalid value: "atlas-victoriametrics-victoria-logs-single-old-server-77cf9cd79d": must be no more than 63 characters 
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the default &lt;a href="https://github.com/VictoriaMetrics/helm-charts/blob/master/charts/victoria-logs-single/values.yaml" rel="noopener noreferrer"&gt;values.yaml&lt;/a&gt; of the victoria-logs-single chart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
  persistentVolume:
    # -- Create/use Persistent Volume Claim for server component. Use empty dir if set to false
    enabled: true
    size: 10Gi
...
  ingress:
    # -- Enable deployment of ingress for server component
    enabled: false
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the &lt;code&gt;vlselect&lt;/code&gt; instance, add the &lt;code&gt;storageNode&lt;/code&gt; parameter, and specify the endpoints of both VictoriaLogs separated by commas, and, if necessary, set parameters for &lt;code&gt;persistentVolume&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;...
vmlogs-new:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 30Gi
    retentionPeriod: 14d

vmlogs-old:
  server:
    persistentVolume:
      enabled: true
      storageClassName: gp2-retain
      size: 30Gi
    retentionPeriod: 14d

vlselect:
  server:
    extraArgs:
      storageNode: atlas-victoriametrics-vmlogs-new-server:9428,atlas-victoriametrics-vmlogs-old-server:9428
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the chart, and check the Pods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get pod | grep 'vmlogs\|vlselect'
atlas-victoriametrics-vlselect-server-0 1/1 Running 0 19h
atlas-victoriametrics-vmlogs-new-server-0 1/1 Running 0 76s
atlas-victoriametrics-vmlogs-old-server-0 1/1 Running 0 76s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk get svc | grep 'vmlogs\|vlselect'
atlas-victoriametrics-vlselect-server ClusterIP None &amp;lt;none&amp;gt; 9428/TCP 22h
atlas-victoriametrics-vmlogs-new-server ClusterIP None &amp;lt;none&amp;gt; 9428/TCP 42s
atlas-victoriametrics-vmlogs-old-server ClusterIP None &amp;lt;none&amp;gt; 9428/TCP 42s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now we have Promtail on the new cluster continuing to write to &lt;code&gt;atlas-victoriametrics-vmlogs-new-server&lt;/code&gt;, and in the &lt;code&gt;atlas-victoriametrics-vmlogs-old-server&lt;/code&gt; we have an empty VMLogs instance.&lt;/p&gt;

&lt;p&gt;We can check access to the logs through the &lt;code&gt;vlselect&lt;/code&gt; instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk port-forward svc/atlas-victoriametrics-vlselect-server 9428
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv4c9hosn4s10z40jwirc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv4c9hosn4s10z40jwirc.png" width="800" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Transferring data from the old cluster
&lt;/h3&gt;

&lt;p&gt;Next, we simply repeat what we did above: find the PVC directory, and copy the data from the old cluster there.&lt;/p&gt;

&lt;p&gt;This time, I’ll first copy data to my work laptop, and then from it will copy to the Kubernetes cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[setevoy@setevoy-work ~] $ mkdir vmlogs_back
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While writing this, VictoriaLogs on the old cluster has already moved to the new EC2, so we’re looking for the data again.&lt;/p&gt;

&lt;p&gt;Switch &lt;code&gt;kubectl&lt;/code&gt; to the old cluster and find the Pod and its WorkerNode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kk describe pod atlas-victoriametrics-victoria-logs-single-server-0 | grep 'Node\|Container'
Node: ip-10-0-38-72.ec2.internal/10.0.38.72
Containers:
    Container ID: containerd://c168d4487282dd7d868aadcfcd1840e4e15cfd360f56f542a98b77978f91e252
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Connect to the EC2, find the directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-38-72 ec2-user]# crictl inspect c168d4487282dd7d868aadcfcd1840e4e15cfd360f56f542a98b77978f91e252
...
    "mounts": [
      {
        "containerPath": "/storage",
        "gidMappings": [],
        "hostPath": "/var/lib/kubelet/pods/f84ef4b9-272f-437e-9f98-649e1707ed09/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount",
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install &lt;code&gt;rsync&lt;/code&gt; there, and copy the data to the laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ rsync -avz --progress -e "ssh -i .ssh/eks_ec2" \
&amp;gt; --rsync-path="sudo rsync" \
&amp;gt; ec2-user@10.0.38.72:/var/lib/kubelet/pods/f84ef4b9-272f-437e-9f98-649e1707ed09/volumes/kubernetes.io~csi/pvc-43c427fa-b05c-45b8-8bdb-92b00bff3496/mount/partitions/ \
&amp;gt; /home/setevoy/vmlogs_back/
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check data locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ll ~/vmlogs_back/
total 32
drwxrwsr-x 4 setevoy setevoy 4096 Jun 19 03:00 20250619
drwxrwsr-x 4 setevoy setevoy 4096 Jun 20 03:00 20250620
drwxrwsr-x 4 setevoy setevoy 4096 Jun 21 03:00 20250621
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, we’ll move everything to the new cluster, where the &lt;code&gt;atlas-victoriametrics-vmlogs-old-server-0&lt;/code&gt; Pod is running.&lt;/p&gt;

&lt;p&gt;Switch &lt;code&gt;kubectl&lt;/code&gt; to the new cluster, find the WorkerNode and Container ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ kd atlas-victoriametrics-vmlogs-old-server-0 | grep 'Node\|Container'
Node: ip-10-0-36-143.ec2.internal/10.0.36.143
Containers:
    Container ID: containerd://f10118b10afab75c43e03adcc0644af5caa8654687cd81e59cdf15bd8c32cb31
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSH to EC2, and find the directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-36-143 ec2-user]# ctr -n k8s.io containers info f10118b10afab75c43e03adcc0644af5caa8654687cd81e59cdf15bd8c32cb31
...
            {
                "destination": "/storage",
                "type": "bind",
                "source": "/var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount",
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check its content, it should be empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;drwxr-sr-x. 2 ec2-user 2000 4096 Jun 26 13:14 partitions
[root@ip-10-0-36-143 ec2-user]# ls -l /var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
total 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install &lt;code&gt;rsync&lt;/code&gt; there, and copy the data from the local directory &lt;code&gt;/home/setevoy/vmlogs_back/&lt;/code&gt; to the new EKS cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ rsync -avz --progress -e "ssh -i .ssh/eks_ec2" --rsync-path="sudo rsync" \
&amp;gt; /home/setevoy/vmlogs_back/ \
&amp;gt; ec2-user@10.0.36.143:/var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the data there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-36-143 ec2-user]# ls -l /var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
total 32
drwxrwsr-x. 4 ec2-user ec2-user 4096 Jun 19 00:00 20250619
drwx--S---. 2 root ec2-user 4096 Jun 26 13:39 20250620
drwx--S---. 2 root ec2-user 4096 Jun 26 13:39 20250621
drwx--S---. 2 root ec2-user 4096 Jun 26 13:39 20250622
drwx--S---. 2 root ec2-user 4096 Jun 26 13:39 20250623
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the user and group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[root@ip-10-0-36-143 ec2-user]# chown -R ec2-user:2000 /var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
[root@ip-10-0-36-143 ec2-user]# ls -l /var/lib/kubelet/pods/297b75ec-63fa-4061-bb23-7a6a120da939/volumes/kubernetes.io~csi/pvc-c7373468-f247-4596-b2e2-87852aad71bb/mount/partitions/
total 32
drwxrwsr-x. 4 ec2-user 2000 4096 Jun 19 00:00 20250619
drwxrwsr-x. 4 ec2-user 2000 4096 Jun 20 00:00 20250620
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart the &lt;code&gt;atlas-victoriametrics-vmlogs-old-server-0&lt;/code&gt; Pod.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking the data
&lt;/h3&gt;

&lt;p&gt;Let’s look for something.&lt;/p&gt;

&lt;p&gt;First, something about the &lt;code&gt;hostname: "ip-10-0-36-143.ec2.internal"&lt;/code&gt; - it's an EC2 from the new EKS cluster, and it should come from the &lt;code&gt;atlas-victoriametrics-vmlogs-new-server-0&lt;/code&gt; instance, i.e. from the old instance on the new Kubernetes cluster:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff4rwieth4viyyvfngsll.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff4rwieth4viyyvfngsll.png" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And now some node from the old cluster:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78bijqkksi2ebq51sxsr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78bijqkksi2ebq51sxsr.png" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything is there.&lt;/p&gt;

&lt;p&gt;Done.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;/em&gt; &lt;a href="https://rtfm.co.ua/en/victoriametrics-migrating-vmsingle-and-victorialogs-data-between-kubernetes-cluster/" rel="noopener noreferrer"&gt;&lt;em&gt;RTFM: Linux, DevOps, and system administration&lt;/em&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>monitoring</category>
      <category>todayilearned</category>
    </item>
  </channel>
</rss>
