<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
  <title>Catalin Popescu — Notes from production</title>
  <link>https://cpopescu.dev/articles/</link>
  <atom:link href="https://cpopescu.dev/articles/feed.xml" rel="self" type="application/rss+xml"/>
  <description>Practical write-ups on Kubernetes, Terraform, CI/CD, observability and LLM engineering.</description>
  <language>en</language>
  <lastBuildDate>Tue, 13 Oct 2026 08:00:00 +0000</lastBuildDate>
  <item>
    <title>A RAG assistant for a small site on AWS: architecture, evals and cost</title>
    <link>https://cpopescu.dev/articles/rag-assistant-on-aws/</link>
    <guid isPermaLink="true">https://cpopescu.dev/articles/rag-assistant-on-aws/</guid>
    <pubDate>Tue, 13 Oct 2026 08:00:00 +0000</pubDate>
    <description>How the assistant on this site works, from chunking and retrieval to the eval gate, abuse limits and the monthly bill, and the decisions I would make again.</description>
    <category>AI</category>
    <category>RAG</category>
    <category>AWS</category>
    <category>Lambda</category>
    <category>Evals</category>
    <content:encoded><![CDATA[<p>The assistant on this site answers questions about my work: experience, services, how a project starts. It is a small retrieval-augmented generation (RAG) system, and it was built the way I build them for clients: measured before it shipped, limited so a bug or an abuser cannot run up the bill, and cheap to keep running. This article walks through the design and the numbers.</p>
<h2 id="what-it-has-to-do-and-what-it-must-not-do">What it has to do, and what it must not do</h2>
<p>A recruiter or a potential client asks a question; the assistant answers from what is written about me and links the sources it used. The rules that shaped everything else:</p>
<ul>
<li><strong>Only from the sources.</strong> No guessed dates, clients, rates or availability. If the sources do not cover a question, the answer says so and points to the contact form.</li>
<li><strong>Answer in the visitor's language.</strong> The site is in English, French and Romanian, and visitors ask in all three, often with typos.</li>
<li><strong>Not a free consultant.</strong> "How do I set up an EKS cluster?" gets a pointer to the matching article and the contact form, not a tutorial.</li>
<li><strong>Bounded cost.</strong> Every request costs money, so abuse has to be stopped before it reaches the model.</li>
</ul>
<h2 id="the-corpus-small-so-chunk-by-meaning">The corpus: small, so chunk by meaning</h2>
<p>The whole corpus is about 7,000 characters of CV, site sections and an FAQ I wrote for the assistant, plus the articles. At that size the usual advice (fixed windows of 300-500 tokens with overlap) does more harm than good: a window would mix two jobs from the CV into one chunk, and the answer could attribute one client's work to another.</p>
<p>Instead, <strong>one chunk is one unit of meaning</strong>: one job, one site section, one FAQ question, one article section. Chunks range from 160 to 1,450 characters, and each carries a recognisable title ("CV · Allianz Technology"), which is what the visitor sees under the answer.</p>
<p>The text comes from the same source file that generates the site, not from the PDF of my CV. Text extracted from a two-column PDF glues columns together, and a single source means the assistant cannot drift out of sync with the site.</p>
<h2 id="retrieval-without-a-vector-database">Retrieval without a vector database</h2>
<p>Each chunk is embedded once with Amazon Titan Text Embeddings v2 on Bedrock (1,024 dimensions, normalised). The result is a JSON file of a few megabytes (2.4 MB at about a hundred chunks), shipped inside the Lambda package. At query time the question is embedded and compared with every chunk by a dot product in NumPy, which takes about 0.02 milliseconds for a hundred chunks.</p>
<p>A managed vector database would add a service, permissions, a network hop and a monthly minimum for no measurable gain at this size. Packaging the index with the code has a side benefit: code and index are deployed together and rolled back together, so they can never disagree.</p>
<p><strong>No relevance threshold.</strong> I planned to drop chunks below a minimum similarity score, then measured. Correct chunks scored between 0.11 and 0.70; questions the corpus cannot answer reached 0.36. The distributions overlap, so any threshold either loses correct answers ("Where did he study?" scores 0.11) or lets unrelated questions through. The top five chunks always go to the model, and the model decides whether they answer the question. That rule lives in the prompt and is covered by the evals.</p>
<h2 id="generation-short-answers-with-citations">Generation: short answers with citations</h2>
<p>The model receives the top five chunks, numbered, and the visitor's question with the last two exchanges for follow-ups. The system prompt is short and specific: use only the sources, cite each sentence as <code>[n]</code>, two to five sentences, third person, the visitor's language, no technical walkthroughs. The <code>[n]</code> markers are removed from the text and turned into the list of links under the answer, so the visitor sees clean prose and can still check every claim.</p>
<p>Follow-up questions such as "and when was that?" have no topic of their own, so retrieval uses the previous question together with the new one.</p>
<p>The fixed instructions are cached between requests, so a repeated system prompt costs a fraction of its first use. Effort is set low: the questions are factual and the corpus is small, so deeper reasoning adds latency without improving answers.</p>
<h2 id="choosing-the-model-with-an-eval-not-a-hunch">Choosing the model with an eval, not a hunch</h2>
<p>I compared two models on the same question set: Claude Opus 5.5 and Claude Haiku 5.5, which is about 40 times cheaper. Both answered all 54 questions correctly. The larger model won on wording: concrete answers, no filler, graceful refusals. It costs about $0.009 per question, which at this site's volume is a few dollars a month, so quality won. On a high-traffic support bot the same eval could just as well have chosen the smaller model.</p>
<p>The eval set now has 75 questions:</p>
<ul>
<li><strong>Answerable questions</strong> (59), mostly in English with some in Romanian and French, graded on whether the answer contains the expected facts and avoids known wrong ones.</li>
<li><strong>Questions only I can answer</strong> (rates that are not published, a start date), where the correct answer is "I don't have that information" plus the contact form.</li>
<li><strong>Off-topic and how-to questions</strong>, where the assistant must decline or point to an article.</li>
<li><strong>Typos</strong>, in English and Romanian. All passed, so there is no spell-correction step: it would add latency and a failure point without a measured gain.</li>
</ul>
<p>Retrieval has its own check: for every question, the right chunk must be in the top five. It currently scores 65 out of 65.</p>
<p>That check caught a real problem when more articles arrived. An article has one chunk per section, so a single article could fill all five slots and push out the FAQ entry that says what I have actually done: the answer would recommend the article without mentioning my experience. Now at most two chunks from the same source make the top five.</p>
<h2 id="an-eval-gate-on-the-way-to-production">An eval gate on the way to production</h2>
<p>Evals that are not enforced get skipped. The site is deployed by CI, but the assistant's Lambda is built and deployed separately, so a CI job would not stop anything. Instead, the eval scripts record a pass against a fingerprint of everything that shapes an answer: the index, the retrieval and prompt code, the questions, and the model. The packaging script refuses to build if the current fingerprint has no recorded pass. Changing one line of the prompt means running the eval again before it can reach production.</p>
<h2 id="serving-it-cloudfront-lambda-and-three-layers-of-limits">Serving it: CloudFront, Lambda and three layers of limits</h2>
<pre><code>Browser -&gt; CloudFront /api/chat (WAF) -&gt; Lambda Function URL (IAM auth, signed by CloudFront)
                                            |-&gt; embeddings (Bedrock)
                                            |-&gt; model API (key in Secrets Manager)
                                            '-&gt; DynamoDB (daily counter, 30-day log)</code></pre>
<ul>
<li><strong>Same origin.</strong> The API sits behind the site's own CloudFront distribution under <code>/api/*</code>, so there is no CORS and no second domain.</li>
<li><strong>Only CloudFront can call the function.</strong> The Function URL uses IAM authentication, and CloudFront signs requests with Origin Access Control. A direct call to the URL gets a 403. For POST requests the browser has to send a SHA-256 hash of the body, which the widget computes with the Web Crypto API.</li>
<li><strong>Limits, outermost first.</strong> A WAF rate limit per IP (20 requests per 5 minutes) plus AWS's list of IPs with a bad reputation; a global daily cap of 200 questions, counted atomically in DynamoDB; input size limits in the function; and a monthly spend limit on the model API account. Each layer fails with a friendly message that points to the contact form.</li>
<li><strong>Privacy.</strong> Questions and answers are kept for 30 days to improve the FAQ. The visitor's IP is never stored, only a salted hash that changes every day, so a visitor cannot be followed from one day to the next. The privacy page says exactly this.</li>
<li><strong>Alerts.</strong> Errors, throttling and a cost budget alert by email.</li>
</ul>
<h2 id="streaming-the-answer">Streaming the answer</h2>
<p>A complete answer takes three to seven seconds, and that long behind a typing indicator feels slow. The answer is now streamed: the first words appear after one to five seconds, depending on how long the model thinks first, and the rest follows as it is written.</p>
<p>Lambda streams responses natively only on Node.js runtimes. Rather than rewrite the assistant or add an adapter layer, the function runs a small Runtime API loop of its own (about 80 lines of standard-library Python) that sends the response in the Function URL streaming format, through CloudFront to the browser. Two details mattered:</p>
<ul>
<li><strong>Citation markers are stripped while streaming.</strong> A filter holds back any tail that could still become a <code>[3]</code>, so the visitor never sees a marker flash and disappear. A randomised test checks that the streamed text equals the stored answer for thousands of different chunk splits.</li>
<li><strong>CloudFront passes the stream straight through.</strong> The first text reaches the browser about 0.2 seconds after the function sends it.</li>
<li><strong>The eval runs through the streaming path.</strong> The non-streaming function used by the eval is built on the streaming one, so what is measured is what visitors get.</li>
</ul>
<h2 id="what-it-costs">What it costs</h2>
<p>At around 900 questions a month, the estimate is:</p>
<div class="table"><table><thead><tr><th>Item</th><th>Monthly cost</th></tr></thead><tbody><tr><td>Model API (about $0.009 per question)</td><td>~$8</td></tr><tr><td>WAF (web ACL, rules, requests)</td><td>~$7</td></tr><tr><td>Lambda, DynamoDB, CloudFront, Secrets Manager</td><td>under $1</td></tr><tr><td>Embeddings (questions only; the corpus is embedded once)</td><td>under $0.01</td></tr></tbody></table></div>
<p>The largest fixed cost is the firewall, not the model. I accept it as the price of putting an endpoint that costs money per call on the public internet.</p>
<h2 id="what-i-would-do-the-same-way-on-a-client-project">What I would do the same way on a client project</h2>
<ul>
<li><strong>Write the eval set before choosing the model</strong>, and let it decide.</li>
<li><strong>Chunk by meaning</strong> when documents have natural units; measure before adding thresholds.</li>
<li><strong>Keep the index next to the code</strong> until the corpus is large enough to need a database.</li>
<li><strong>Put the eval on the deployment path</strong>, not in a report.</li>
<li><strong>Limit cost in layers</strong>, outside the code first.</li>
</ul>
<p>If you are planning an assistant over your own documentation, support content or internal knowledge, this is the kind of work I do. The <a href="https://cpopescu.dev/ai-engineering/">AI engineering page</a> describes how a project starts, and the contact form is the quickest way to reach me.</p>]]></content:encoded>
  </item>
  <item>
    <title>EKS vs GKE for a mid-size team: cost and operational trade-offs</title>
    <link>https://cpopescu.dev/articles/eks-vs-gke-mid-size-team/</link>
    <guid isPermaLink="true">https://cpopescu.dev/articles/eks-vs-gke-mid-size-team/</guid>
    <pubDate>Tue, 13 Oct 2026 08:00:00 +0000</pubDate>
    <description>Where Amazon EKS and Google GKE really differ for a team of five to fifty engineers, what each costs beyond the price list, and how I help a team choose.</description>
    <category>Kubernetes</category>
    <category>EKS</category>
    <category>GKE</category>
    <category>AWS</category>
    <category>GCP</category>
    <content:encoded><![CDATA[<p>I build and run clusters on both Amazon EKS and Google GKE, and the question comes up in almost every new project: which one should we use? For a mid-size team, say five to fifty engineers with one or two people who own the platform, the honest answer is that both run Kubernetes well. The difference is in what you have to build and operate around the cluster, and that is where the cost hides.</p>
<h2 id="start-with-where-the-rest-of-your-system-lives">Start with where the rest of your system lives</h2>
<p>Before comparing clusters, look at everything else. A cluster is rarely the only thing a team runs: there are databases, queues, object storage, identity, networking to offices or partners, and often a data platform.</p>
<ul>
<li><strong>If most of it is already on AWS</strong>, EKS is usually the right call. Pods reach RDS, SQS and S3 over the same VPC, with IAM roles you already manage, and data stays inside one provider's network and bill.</li>
<li><strong>If you are on Google Cloud</strong>, or your data lives in BigQuery, GKE is the natural choice for the same reasons.</li>
<li><strong>Starting from nothing</strong>, both are viable. Then the operational trade-offs below decide it.</li>
</ul>
<p>Cross-cloud traffic costs money and adds latency, and two IAM systems double the access reviews. Moving a cluster to the "better" Kubernetes service while the databases stay behind is almost never worth it.</p>
<h2 id="what-the-price-list-says">What the price list says</h2>
<p>The control plane is the easy part to compare. At the time of writing:</p>
<div class="table"><table><thead><tr><th>Item</th><th>EKS</th><th>GKE</th></tr></thead><tbody><tr><td>Control plane, per cluster</td><td>$0.10 per hour (about $73 a month)</td><td>$0.10 per hour (about $73 a month)</td></tr><tr><td>Free allowance</td><td>none</td><td>monthly credit covering one zonal or Autopilot cluster</td></tr><tr><td>Version past standard support</td><td>$0.60 per hour (extended support)</td><td>$0.60 per hour (extended period)</td></tr></tbody></table></div>
<p>Prices change, so check the <a href="https://aws.amazon.com/eks/pricing/" target="_blank" rel="noopener">EKS pricing page</a> and the <a href="https://cloud.google.com/kubernetes-engine/pricing" target="_blank" rel="noopener">GKE pricing page</a> before you budget.</p>
<p>Two things in this table matter more than the base fee:</p>
<ul>
<li><strong>Extended support is a tax on not upgrading.</strong> Running an old Kubernetes version costs six times the normal control plane fee on both platforms. A team that cannot upgrade regularly pays for it every hour. The fix is a tested upgrade procedure, not a cheaper provider.</li>
<li><strong>The fee is per cluster.</strong> One cluster per environment per team multiplies quickly. Three environments for four teams is twelve control planes before a single workload runs. Fewer, shared clusters with namespaces and policies are usually cheaper and easier to keep upgraded.</li>
</ul>
<h2 id="where-the-real-money-goes">Where the real money goes</h2>
<p>For a mid-size team, the control plane is a rounding error next to these:</p>
<ul>
<li><strong>Compute.</strong> Nodes are most of the bill on both platforms. What matters is how well they are packed: requests that match real usage, autoscaling that removes idle nodes, and spot or preemptible capacity for workloads that tolerate interruption. A badly sized cluster on the cheaper platform costs more than a well-sized one on the other.</li>
<li><strong>Networking.</strong> NAT gateways, load balancers and cross-zone traffic add up. On AWS in particular, a NAT gateway per zone plus data processing charges can surprise a team that pulls large images or talks to external APIs a lot. VPC endpoints for the services you call most reduce it.</li>
<li><strong>Observability.</strong> Managed logging and metrics on either platform charge by volume. Verbose application logs shipped by default can cost more than the cluster.</li>
<li><strong>People.</strong> The hours your platform engineers spend on upgrades, add-ons and incidents. This is where the two services differ most.</li>
</ul>
<h2 id="operational-trade-offs">Operational trade-offs</h2>
<h3 id="gke-does-more-out-of-the-box">GKE does more out of the box</h3>
<p>GKE has been managed Kubernetes for longer, and it shows in the defaults:</p>
<ul>
<li><strong>Autopilot</strong> runs the nodes for you. You describe pods; Google provisions and patches the capacity, and you pay for what the pods request. For a team without a dedicated platform engineer, this removes a whole category of work.</li>
<li><strong>Release channels and maintenance windows</strong> make upgrades routine. Clusters follow a channel and upgrade inside the windows you set.</li>
<li><strong>Integrated logging and metrics</strong> are on by default.</li>
</ul>
<p>The trade-off is control. Autopilot restricts privileged workloads, some DaemonSets and node-level tuning. Most business applications never notice; some security and networking agents do.</p>
<h3 id="eks-gives-you-more-pieces-to-assemble">EKS gives you more pieces to assemble</h3>
<p>An EKS cluster is closer to raw Kubernetes. A production setup needs decisions and code for:</p>
<ul>
<li>the <strong>VPC CNI</strong> and IP address planning, since every pod gets a VPC address;</li>
<li>a <strong>node autoscaler</strong>, usually Karpenter, which is very good at bin packing and spot usage once configured;</li>
<li>the <strong>AWS Load Balancer Controller</strong>, ExternalDNS and certificates;</li>
<li><strong>pod identity</strong> so workloads get AWS permissions through their own roles;</li>
<li><strong>add-on versions</strong> that must be upgraded together with the cluster.</li>
</ul>
<p>EKS has closed part of the gap with managed add-ons, EKS Auto Mode and Pod Identity, but the cluster still asks more design decisions of you up front. In exchange you get fine control over networking, instance types and how capacity is bought, and everything is in the same AWS account model as the rest of your infrastructure.</p>
<h3 id="upgrades">Upgrades</h3>
<p>Both providers release a new Kubernetes minor version every few months, and both charge extra for staying behind. In practice:</p>
<ul>
<li><strong>GKE</strong> upgrades the control plane and nodes for you within the release channel and maintenance windows. Your job is to keep workloads compatible: deprecated APIs, Pod Disruption Budgets and enough replicas.</li>
<li><strong>EKS</strong> upgrades the control plane on request; nodes and add-ons are yours to roll. With managed node groups or Karpenter this is a routine, scripted job, but it is a job.</li>
</ul>
<p>On either platform, the clusters that fall behind are the ones where nobody owns the upgrade. Whatever you choose, put the procedure in writing and run it on a staging cluster first.</p>
<h2 id="how-i-help-a-team-decide">How I help a team decide</h2>
<p>When a team asks me to choose, I go through these questions:</p>
<ol>
<li><strong>Where do the data and the other services live?</strong> This usually decides it.</li>
<li><strong>Who will operate the cluster?</strong> No dedicated platform person points towards GKE Autopilot, or towards a deliberately small EKS setup built entirely in Terraform with few add-ons.</li>
<li><strong>Do you need node-level control?</strong> Custom networking, privileged agents or specific instance types favour EKS or GKE Standard.</li>
<li><strong>How many clusters do you really need?</strong> Usually fewer than planned.</li>
<li><strong>What will the first year cost</strong>, including compute at realistic utilisation, networking and observability, not only the control plane?</li>
</ol>
<p>Either way, the cluster goes into Terraform from day one, with the same review process as application code. The checklist from <a href="https://cpopescu.dev/articles/production-ready-eks-cluster-terraform/">A production-ready Kubernetes cluster on EKS with Terraform</a> applies to GKE almost unchanged.</p>
<h2 id="the-short-version">The short version</h2>
<ul>
<li>Pick the platform where the rest of your system already lives.</li>
<li>Starting fresh with a small platform team, GKE Autopilot needs the least operational work.</li>
<li>If you need fine control or are already deep in AWS, EKS is excellent once the add-ons are in code.</li>
<li>The control plane fee is the same; compute packing, networking, observability and people decide the bill.</li>
<li>Upgrade on schedule. Extended support costs six times the standard fee on both.</li>
</ul>
<p>If you are choosing between the two, or want a review of what an existing cluster really costs, this is the kind of work I do. The <a href="https://cpopescu.dev/#contact">contact form</a> is the quickest way to reach me.</p>]]></content:encoded>
  </item>
  <item>
    <title>Prometheus alerting that doesn&#x27;t wake you up for nothing</title>
    <link>https://cpopescu.dev/articles/prometheus-alerting-without-noise/</link>
    <guid isPermaLink="true">https://cpopescu.dev/articles/prometheus-alerting-without-noise/</guid>
    <pubDate>Tue, 13 Oct 2026 08:00:00 +0000</pubDate>
    <description>How to design Prometheus and Alertmanager alerts that page only for real user impact: symptom-based rules, SLO burn rates, routing, and a review loop that keeps noise down.</description>
    <category>Prometheus</category>
    <category>Alertmanager</category>
    <category>Grafana</category>
    <category>Observability</category>
    <category>SRE</category>
    <content:encoded><![CDATA[<p>The fastest way to make on-call useless is to page people for things that do not matter. After a few nights of "CPU above 80%" at 3 a.m. with nothing broken, engineers mute the channel, and the alert that does matter gets missed. Most noisy setups I see have the same causes, and they can be fixed without buying anything. This is how I design Prometheus alerting so that a page means someone has to act now.</p>
<h2 id="rule-one-page-on-symptoms-not-causes">Rule one: page on symptoms, not causes</h2>
<p>A page should mean users are affected, or will be very soon. That means alerting on what users experience:</p>
<ul>
<li>requests failing (error rate);</li>
<li>requests too slow (latency);</li>
<li>the service unreachable;</li>
<li>work not getting done (queue age, jobs not completing).</li>
</ul>
<p>High CPU, a pod restart, a full-ish disk or a replica down are causes. They might lead to user impact, or they might not: autoscaling, retries and redundancy exist precisely so that they don't. Causes belong on dashboards and in tickets, not in someone's phone at night.</p>
<p>The exception is a cause that will certainly become an outage with no automatic recovery, such as a disk that will be full within hours, or a certificate that expires in days. Those are predictable, so alert on the prediction:</p>
<pre><code class="lang-yaml">- alert: DiskWillFillIn4Hours
  expr: predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 4 * 3600) &lt; 0
  for: 30m
  labels:
    severity: page
  annotations:
    summary: "{{ $labels.instance }} {{ $labels.mountpoint }} will be full within 4 hours"
    runbook: "https://runbooks.example.com/disk-full"</code></pre>
<h2 id="two-severities-two-destinations">Two severities, two destinations</h2>
<p>I use two levels and resist adding more:</p>
<ul>
<li><strong>page</strong>: a person must act now, at any hour. Goes to the on-call phone.</li>
<li><strong>ticket</strong>: someone must look within working hours. Goes to a channel or the issue tracker.</li>
</ul>
<p>Everything else is not an alert. If no one would do anything about it, it is a dashboard panel. A "warning" level that goes to a channel nobody reads is just noise with a nicer name.</p>
<h2 id="alert-on-error-budgets-not-on-single-spikes">Alert on error budgets, not on single spikes</h2>
<p>A five-minute error spike that fixes itself does not need a human. A small but steady error rate that will eat a month's reliability target in two days does. Static thresholds handle both badly. SLO burn-rate alerts handle both well.</p>
<p>Start with a target, for example: 99.9% of requests succeed over 30 days. That leaves an error budget of 0.1%. The burn rate is how fast you are spending it: a burn rate of 1 uses the budget in exactly 30 days, and 14.4 uses it in about two days.</p>
<p>First, a recording rule for the error ratio over several windows:</p>
<pre><code class="lang-yaml">groups:
- name: api-slo
  rules:
  - record: job:request_errors:ratio_rate5m
    expr: |
      sum by (job) (rate(http_requests_total{code=~"5.."}[5m]))
      /
      sum by (job) (rate(http_requests_total[5m]))
  - record: job:request_errors:ratio_rate1h
    expr: |
      sum by (job) (rate(http_requests_total{code=~"5.."}[1h]))
      /
      sum by (job) (rate(http_requests_total[1h]))</code></pre>
<p>Then alert when both a long and a short window burn fast. The long window proves it is sustained; the short one makes the alert stop soon after the problem is fixed:</p>
<pre><code class="lang-yaml">  - alert: ApiErrorBudgetBurn
    expr: |
      job:request_errors:ratio_rate1h{job="api"} &gt; (14.4 * 0.001)
      and
      job:request_errors:ratio_rate5m{job="api"} &gt; (14.4 * 0.001)
    for: 2m
    labels:
      severity: page
    annotations:
      summary: "API is burning its error budget 14x too fast"
      runbook: "https://runbooks.example.com/api-errors"</code></pre>
<p>A second rule with a lower burn rate over longer windows (for example 6 hours and 30 minutes at a burn rate of 6) catches slower problems. For slow, steady leaks, a ticket-level alert at a burn rate of 1 over three days is enough. This multi-window approach is described in detail in Google's SRE workbook, and it is the single change that removes the most noise in the setups I review.</p>
<h2 id="make-every-alert-actionable">Make every alert actionable</h2>
<p>Each alert rule should answer three questions for the person who receives it:</p>
<ul>
<li><strong>What is broken</strong>, in the summary, in plain words and with the affected service or instance.</li>
<li><strong>How bad</strong>, through the severity and, where useful, the current value.</li>
<li><strong>What to do</strong>, through a runbook link: how to confirm the problem, the usual causes, and how to mitigate.</li>
</ul>
<p>An alert without a runbook is a sign the alert is not understood yet. Writing the runbook often shows that the alert should be a dashboard panel instead.</p>
<p>Use <code>for:</code> so that a single bad scrape does not page anyone, but keep it short on paging alerts. The burn-rate windows already smooth out spikes.</p>
<h2 id="let-alertmanager-do-the-grouping">Let Alertmanager do the grouping</h2>
<p>Many noisy setups are really routing problems. Alertmanager can turn fifty notifications into one:</p>
<pre><code class="lang-yaml">route:
  receiver: tickets
  group_by: [alertname, cluster, service]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers: [severity="page"]
      receiver: on-call
      repeat_interval: 1h

inhibit_rules:
  - source_matchers: [alertname="ClusterUnreachable"]
    target_matchers: [severity=~"page|ticket"]
    equal: [cluster]</code></pre>
<ul>
<li><strong>Grouping</strong> sends one notification for all pods of a failing service, not one per pod.</li>
<li><strong>Inhibition</strong> suppresses the symptoms of a bigger failure. If the whole cluster is unreachable, nobody needs forty more alerts about the services in it.</li>
<li><strong>Silences</strong> are for planned maintenance. Create them with an expiry and a comment, never as a permanent mute.</li>
</ul>
<h2 id="test-alerts-like-code">Test alerts like code</h2>
<p>Alert rules are code, so they live in Git and go through review. Two tools make that real:</p>
<ul>
<li><code>promtool check rules</code> in CI catches syntax errors before they reach Prometheus.</li>
<li><code>promtool test rules</code> runs unit tests: given this series of metric values, this alert must fire after this many minutes, with these labels. It is the only way to be sure an alert fires when it should and stays quiet when it should not.</li>
</ul>
<p>A dead man's switch completes the picture: an alert that always fires, routed to an external service that pages you if it stops arriving. Otherwise a broken Prometheus or Alertmanager is silent, which looks exactly like a quiet night.</p>
<h2 id="review-the-pages-every-week">Review the pages every week</h2>
<p>The rules above get you a good start. What keeps alerting healthy is a short weekly review of every page from the past week:</p>
<ul>
<li>Was it real? Did someone have to act?</li>
<li>Was it actionable from the alert and the runbook alone?</li>
<li>Could it have waited until morning?</li>
</ul>
<p>Every page that fails one of these questions gets a change: delete it, downgrade it to a ticket, raise the threshold, fix the runbook, or fix the underlying problem so it stops happening. A useful target is no more than one or two pages per on-call shift; beyond that, people stop trusting the pager.</p>
<h2 id="summary">Summary</h2>
<ul>
<li>Page on user-facing symptoms. Causes go on dashboards, except predictable failures.</li>
<li>Two severities: page now, or ticket for working hours.</li>
<li>Use SLO burn-rate alerts with a long and a short window instead of static thresholds.</li>
<li>Every alert has a clear summary and a runbook.</li>
<li>Group and inhibit in Alertmanager; silences expire.</li>
<li>Test rules with <code>promtool</code>, and add a dead man's switch.</li>
<li>Review every page weekly and remove the ones that did not need a human.</li>
</ul>
<p>If your team is tired of its pager, or you are setting up Prometheus and Grafana for the first time, this is the kind of work I do: dashboards, alerts and SLOs that the team trusts. The <a href="https://cpopescu.dev/#contact">contact form</a> is the quickest way to reach me.</p>]]></content:encoded>
  </item>
  <item>
    <title>Terraform module structure for multi-environment AWS</title>
    <link>https://cpopescu.dev/articles/terraform-multi-environment-aws/</link>
    <guid isPermaLink="true">https://cpopescu.dev/articles/terraform-multi-environment-aws/</guid>
    <pubDate>Tue, 13 Oct 2026 08:00:00 +0000</pubDate>
    <description>A layout for Terraform that keeps dev, staging and production consistent without copy-paste: modules, environment roots, state per account, and the review flow that keeps it safe.</description>
    <category>Terraform</category>
    <category>AWS</category>
    <category>Infrastructure as code</category>
    <content:encoded><![CDATA[<p>Most Terraform code I am asked to fix has the same history. It started as one directory for one environment. Then staging was needed, so the directory was copied. Then production. A year later the three copies have drifted, nobody is sure which differences are intentional, and every change has to be made three times. This is the structure I use instead, and how I move existing code into it.</p>
<h2 id="the-goals">The goals</h2>
<p>A multi-environment layout should give you:</p>
<ul>
<li><strong>One definition, several instances.</strong> Staging and production are built from the same code. Differences are explicit inputs, not edits in a copied file.</li>
<li><strong>Small blast radius.</strong> A change to the DNS layer cannot destroy a database. A mistake in dev cannot touch production.</li>
<li><strong>Promotion.</strong> A change is applied to dev, then staging, then production, with the same code at each step.</li>
<li><strong>Readable plans.</strong> A reviewer can see exactly what will change, in which environment, before it happens.</li>
</ul>
<h2 id="accounts-first">Accounts first</h2>
<p>On AWS, I put each environment in its own account: dev, staging and production at least, plus a shared account for things like DNS and container registries. AWS Organizations and IAM Identity Center make this cheap to run.</p>
<p>Separate accounts are the strongest isolation AWS offers. Permissions, quotas and billing are separated by default, and a wrong role in a pipeline cannot reach production resources because it does not have credentials there. Folders and workspaces inside one account do not give you that.</p>
<h2 id="the-layout">The layout</h2>
<pre><code>modules/
  network/          # VPC, subnets, NAT, endpoints
  eks-cluster/      # cluster, node groups, add-ons
  rds-postgres/     # database, parameter group, alarms
  service/          # one application's IAM, queues, buckets
live/
  dev/
    network/        # main.tf calls modules/network with dev inputs
    eks/
    data/
  staging/
    network/
    eks/
    data/
  prod/
    network/
    eks/
    data/</code></pre>
<p>Two kinds of directories, with different rules:</p>
<ul>
<li><strong><code>modules/</code></strong> contain resources and logic, and no environment names. They take inputs, such as CIDR ranges, instance sizes and the number of replicas, and expose outputs. They have no backend and no provider credentials.</li>
<li><strong><code>live/&lt;env&gt;/&lt;component&gt;/</code></strong> are root modules: the directories you run <code>terraform plan</code> in. Each one is short. It configures the backend and provider for its account, calls one or more modules with that environment's values, and nothing else.</li>
</ul>
<h2 id="split-state-by-component">Split state by component</h2>
<p>Each directory under <code>live/</code> has its own state. I split by how often things change and how much damage a mistake can do:</p>
<ul>
<li><strong>network</strong>: changes rarely, everything depends on it;</li>
<li><strong>cluster or compute</strong>: changes with upgrades;</li>
<li><strong>data</strong>: databases and buckets, protected with <code>prevent_destroy</code> and deletion protection;</li>
<li><strong>services</strong>: change often, owned by application teams.</li>
</ul>
<p>A small state plans in seconds and can only break what it contains. Components read what they need from each other through outputs, either with <code>terraform_remote_state</code> or, better, by looking resources up by tag or name with data sources, so the dependency is on the resource and not on another state file's layout.</p>
<p>State lives in S3 in each environment's own account, with locking and versioning. Recent Terraform versions lock with a lock file in the same bucket, so a separate DynamoDB table is no longer needed.</p>
<h2 id="modules-worth-having">Modules worth having</h2>
<p>The temptation is to wrap every AWS resource in a module. That produces a thin layer that hides nothing and must be updated with every provider release. I write modules for units that mean something to the team:</p>
<ul>
<li><strong>"A network"</strong>, not "a subnet": VPC, subnets in three zones, routing, NAT and endpoints, with sensible defaults.</li>
<li><strong>"A Postgres database for a service"</strong>: the instance or cluster, parameter group, subnet group, security group, backups, alarms and the secret.</li>
<li><strong>"A service"</strong>: its IAM role, queues, buckets and the permissions between them.</li>
</ul>
<p>A good module has few required inputs, safe defaults (encryption on, public access off, deletion protection on for data), and outputs that other components need. Where a well-maintained community module already does the job, such as the VPC or EKS modules, I use it pinned to a version rather than rewriting it.</p>
<h2 id="versioning-and-promotion">Versioning and promotion</h2>
<p>Modules are versioned. In a mono-repo that can be a Git tag referenced by <code>?ref=</code>; in a larger organisation, a private module registry. Each environment pins a version:</p>
<pre><code class="lang-hcl">module "network" {
  source = "git::https://gitlab.example.com/platform/terraform-modules.git//network?ref=network-v1.4.0"

  name       = "prod"
  cidr_block = "10.30.0.0/16"
  azs        = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
}</code></pre>
<p>Promotion is then a one-line change: dev moves to <code>network-v1.5.0</code> first, then staging, then production. The diff between environments is visible in Git, and rolling back is reverting that line.</p>
<p>For a small team with one repository, relative paths (<code>source = "../../../modules/network"</code>) are fine, as long as changes are still applied environment by environment.</p>
<h2 id="keeping-the-root-modules-short">Keeping the root modules short</h2>
<p>The copy-paste problem comes back if root modules grow. A few rules keep them thin:</p>
<ul>
<li><strong>Environment differences are inputs.</strong> Sizes, counts, CIDRs and feature flags go into the module call or a small <code>terraform.tfvars</code>. No <code>if env == "prod"</code> logic inside modules; pass the value instead.</li>
<li><strong>Providers carry the account.</strong> Each root assumes a role in its environment's account and sets default tags (environment, owner, cost centre), so every resource is tagged without anyone remembering to.</li>
<li><strong>Pin versions</strong> of Terraform and providers in every root, and upgrade them deliberately, environment by environment.</li>
</ul>
<p>Wrappers like Terragrunt can generate the backend and provider blocks for you. They are useful with dozens of roots; with a handful, plain Terraform and a short template are easier for everyone to read.</p>
<h2 id="the-review-and-apply-flow">The review and apply flow</h2>
<p>Structure only helps if changes go through it:</p>
<ol>
<li>A merge request changes a module or a root.</li>
<li>CI runs <code>terraform fmt -check</code>, <code>validate</code>, a linter and a security scanner, then <code>plan</code> for every root the change touches, and posts the plans on the merge request.</li>
<li>A reviewer reads the plan, not only the code.</li>
<li>After merge, CI applies the saved plan to dev automatically; staging and production apply after a manual approval, using the plan that was reviewed.</li>
</ol>
<p>Nobody applies from a laptop, and nobody clicks in the console. If something has to be changed by hand during an incident, it is imported or reverted the next day, and a scheduled drift check (<code>plan</code> with no changes expected) shows anything that was missed.</p>
<h2 id="moving-existing-code-into-this-layout">Moving existing code into this layout</h2>
<p>You rarely start from zero. The migration I use keeps resources in place:</p>
<ol>
<li><strong>Pick one environment's copy as the reference</strong>, usually production, and turn it into modules with the same resources.</li>
<li><strong>Move state, not resources.</strong> <code>moved</code> blocks, or <code>terraform state mv</code> for older versions, map old addresses to the new module paths. The plan must show no changes before anything else happens.</li>
<li><strong>Bring the other environments onto the same modules one at a time</strong>, turning every difference into an explicit input. Each difference you find is a question for the team: intentional, or drift?</li>
<li><strong>Import what was created by hand</strong>, with <code>import</code> blocks, so it is managed from then on.</li>
</ol>
<p>Done carefully, every step ends with a plan that shows no changes, and production never notices.</p>
<h2 id="summary">Summary</h2>
<ul>
<li>One account per environment; state per environment and component.</li>
<li>Modules for meaningful units, with safe defaults and no environment names.</li>
<li>Root modules that only wire modules together for one environment.</li>
<li>Pinned module versions, promoted from dev to production.</li>
<li>Plans reviewed in merge requests and applied by CI.</li>
</ul>
<p>If your Terraform has grown into copied directories, or you are setting up AWS environments from scratch, this is work I do often. The <a href="https://cpopescu.dev/#contact">contact form</a> is the quickest way to start.</p>]]></content:encoded>
  </item>
  <item>
    <title>A production-ready Kubernetes cluster on EKS with Terraform</title>
    <link>https://cpopescu.dev/articles/production-ready-eks-cluster-terraform/</link>
    <guid isPermaLink="true">https://cpopescu.dev/articles/production-ready-eks-cluster-terraform/</guid>
    <pubDate>Sat, 10 Oct 2026 08:00:00 +0000</pubDate>
    <description>What &quot;production-ready&quot; means for an EKS cluster, how I lay out the Terraform, and the checklist I go through before the first workload goes live.</description>
    <category>Kubernetes</category>
    <category>EKS</category>
    <category>Terraform</category>
    <category>AWS</category>
    <content:encoded><![CDATA[<p>Creating an EKS cluster takes one command. Running one that a team can trust with production traffic is a different job: networking, identity, add-ons, autoscaling, observability and an upgrade path all have to be decided up front, and all of it has to live in code. This is how I approach it.</p>
<h2 id="what-production-ready-means">What "production-ready" means</h2>
<p>Before writing any Terraform I agree on a short definition with the team. A cluster is ready for production when:</p>
<ul>
<li><strong>Everything is in code.</strong> The VPC, the cluster, node groups, add-ons and access rules are created by Terraform from a reviewed merge request. Nobody clicks in the console.</li>
<li><strong>It survives a zone failure.</strong> Nodes and load balancers span at least three availability zones, and workloads run more than one replica.</li>
<li><strong>Access is identity-based.</strong> People and pipelines get Kubernetes permissions through IAM, and pods get AWS permissions through their own roles, never through the node role.</li>
<li><strong>It scales on its own.</strong> Pods scale on load, nodes scale on pending pods, and there are limits so a bug cannot scale the bill.</li>
<li><strong>You can see what it does.</strong> Metrics, logs and alerts exist before the first release, not after the first incident.</li>
<li><strong>It can be upgraded.</strong> There is a tested procedure to move to the next Kubernetes version every few months.</li>
</ul>
<h2 id="network-first">Network first</h2>
<p>The cluster lives in its own VPC with private subnets for nodes and public subnets only for load balancers. A few decisions here are expensive to change later:</p>
<ul>
<li><strong>CIDR size.</strong> The VPC CNI gives every pod a VPC IP address. A /16 with /19 private subnets leaves room to grow; a /24 does not. If addresses are tight, prefix delegation or a secondary CIDR for pods are the usual fixes.</li>
<li><strong>Three availability zones</strong>, one private and one public subnet in each.</li>
<li><strong>NAT gateways.</strong> One per zone for resilience, or a single one to save cost in non-production environments. VPC endpoints for ECR, S3 and STS cut NAT traffic for image pulls and API calls.</li>
<li><strong>Subnet tags</strong> so the load balancer controller knows where to place internal and internet-facing load balancers.</li>
</ul>
<h2 id="terraform-layout">Terraform layout</h2>
<p>I keep the cluster in its own Terraform root module with remote state and locking, separate from the applications that run on it. Most of the heavy lifting is done by the well-maintained community modules for the VPC and EKS, pinned to a major version:</p>
<pre><code class="lang-hcl">module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~&gt; 21.0"

  name               = "platform-prod"
  kubernetes_version = var.kubernetes_version

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  endpoint_public_access = false   # API reachable from the VPC / VPN only
  enable_irsa            = true

  eks_managed_node_groups = {
    system = {
      instance_types = ["m7g.large"]
      ami_type       = "AL2023_ARM_64_STANDARD"
      min_size       = 3
      max_size       = 6
      desired_size   = 3
    }
  }
}</code></pre>
<p>A few habits that save trouble later:</p>
<ul>
<li><strong>Pin everything</strong>: provider versions, module versions and the Kubernetes version. Upgrades become deliberate merge requests instead of surprises.</li>
<li><strong>One state per environment</strong>, with the same code and different variables, so staging really rehearses production.</li>
<li><strong>Plan in CI, apply after review.</strong> The pipeline posts the plan on the merge request; a person reads it before anything changes.</li>
</ul>
<h2 id="identity-and-access">Identity and access</h2>
<p>Two separate questions: who can talk to the Kubernetes API, and what AWS resources a pod can reach.</p>
<ul>
<li><strong>People and pipelines</strong> get access through EKS access entries mapped to IAM roles, with Kubernetes RBAC on top. Engineers assume a role through SSO; the deploy pipeline has its own narrow role.</li>
<li><strong>Pods</strong> get AWS permissions through IAM roles for service accounts (or EKS Pod Identity). Each service gets only what it needs, such as reading one S3 bucket or one secret.</li>
<li><strong>Nodes</strong> keep a minimal role, and IMDSv2 with a hop limit of 1 stops pods from borrowing the node's credentials.</li>
</ul>
<h2 id="add-ons-that-every-cluster-needs">Add-ons that every cluster needs</h2>
<p>I manage these with Terraform or Helm, versioned like everything else:</p>
<ul>
<li><strong>Core EKS add-ons</strong>: VPC CNI, CoreDNS, kube-proxy and the EBS CSI driver for persistent volumes.</li>
<li><strong>AWS Load Balancer Controller</strong> to expose services through ALBs and NLBs from Ingress and Service objects.</li>
<li><strong>External DNS and cert-manager</strong>, if the cluster owns DNS records and certificates.</li>
<li><strong>External Secrets</strong> to sync values from Secrets Manager or Parameter Store into Kubernetes, so secrets never sit in Git.</li>
<li><strong>metrics-server</strong>, which the horizontal pod autoscaler depends on.</li>
</ul>
<h2 id="autoscaling-and-cost">Autoscaling and cost</h2>
<ul>
<li><strong>Pods</strong>: horizontal pod autoscalers on CPU, memory or request rate, with sensible minimum replicas and pod disruption budgets.</li>
<li><strong>Nodes</strong>: Karpenter (or the Cluster Autoscaler) adds capacity when pods are pending and removes empty nodes. Graviton instances and Spot capacity for stateless workloads usually cut compute cost noticeably.</li>
<li><strong>Guardrails</strong>: resource requests and limits on every workload, namespace quotas and a maximum node count, so a runaway deployment cannot grow without bounds.</li>
</ul>
<h2 id="observability-from-day-one">Observability from day one</h2>
<ul>
<li><strong>Metrics</strong>: Prometheus scraping the cluster and the applications, Grafana dashboards per service, and alerts on symptoms users feel (error rate, latency, saturation) rather than on every CPU spike.</li>
<li><strong>Logs</strong>: container logs shipped to a central store with a retention policy.</li>
<li><strong>Control plane logs</strong>: API server and audit logs enabled in EKS, so you can answer "who changed this?".</li>
</ul>
<h2 id="security-baseline">Security baseline</h2>
<ul>
<li>Private API endpoint, or a public one restricted to known CIDRs.</li>
<li>Pod Security Standards enforced per namespace, with <code>restricted</code> as the default for application namespaces.</li>
<li>Network policies so namespaces cannot talk to each other unless they need to.</li>
<li>Image scanning in the pipeline, and images pulled only from your own registry.</li>
<li>Encryption of Kubernetes secrets with a KMS key.</li>
</ul>
<h2 id="upgrades">Upgrades</h2>
<p>EKS versions leave standard support after about 14 months, so upgrades are routine work, not a project. My usual order is: read the upgrade notes and check for deprecated APIs, upgrade staging, then the control plane in production, then add-ons, then node groups with a rolling replacement that respects pod disruption budgets. Because everything is in Terraform, the upgrade is a short merge request plus a checklist.</p>
<h2 id="checklist-before-the-first-workload">Checklist before the first workload</h2>
<ol>
<li>VPC across three zones, private nodes, endpoints for ECR, S3 and STS.</li>
<li>Cluster, node groups and add-ons created from Terraform in CI, state remote and locked.</li>
<li>Access entries and RBAC for people and pipelines; IRSA or Pod Identity for workloads; IMDSv2 enforced.</li>
<li>Load balancer controller, External Secrets and metrics-server installed and versioned.</li>
<li>Pod and node autoscaling with limits; requests and limits on every workload.</li>
<li>Prometheus, Grafana, logs and alerts working, with a test alert delivered.</li>
<li>Pod Security Standards, network policies and secret encryption enabled.</li>
<li>Upgrade procedure written down and rehearsed on staging.</li>
</ol>
<p>If your team needs a cluster like this, or a review of one that already runs in production, I can help: I design and build it with you, and hand it over with documentation your team can maintain.</p>]]></content:encoded>
  </item>
</channel>
</rss>
