<?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>DEV Community: Metronom</title>
    <description>The latest articles on DEV Community by Metronom (@mtrnm).</description>
    <link>https://dev.to/mtrnm</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4037870%2F0250143b-258c-4970-929e-847fa83a53e4.png</url>
      <title>DEV Community: Metronom</title>
      <link>https://dev.to/mtrnm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mtrnm"/>
    <language>en</language>
    <item>
      <title>You can catch production failures on your laptop: probes, OOMKilled, and zero-downtime in k3d</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Tue, 08 Sep 2026 10:27:02 +0000</pubDate>
      <link>https://dev.to/mtrnm/you-can-catch-production-failures-on-your-laptop-probes-oomkilled-and-zero-downtime-in-k3d-51l7</link>
      <guid>https://dev.to/mtrnm/you-can-catch-production-failures-on-your-laptop-probes-oomkilled-and-zero-downtime-in-k3d-51l7</guid>
      <description>&lt;p&gt;"Works on my laptop" and "survives production" are different things — but almost everything separating them is testable locally in k3d. Probe behavior, liveness restarts, rolling updates, SIGTERM shutdown: k3d reproduces all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU throttles, memory kills.&lt;/strong&gt; CPU is compressible — hit the limit and the container just slows down. Memory is not — exceed &lt;code&gt;limits.memory&lt;/code&gt; and the kernel kills the process: &lt;strong&gt;OOMKilled, exit code 137&lt;/strong&gt;. So a low CPU limit = slowness; a low memory limit = instant Pod death. Give memory headroom (request ~P99 + 20%); CPU can ride closer to P95.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three probes, three jobs:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;liveness&lt;/strong&gt; fails -&amp;gt; container is &lt;strong&gt;restarted&lt;/strong&gt;. Keep it simple (&lt;code&gt;GET /healthz&lt;/code&gt;), &lt;em&gt;never&lt;/em&gt; hit the DB — a dependency blip would restart a healthy container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;readiness&lt;/strong&gt; fails -&amp;gt; Pod is pulled from the Service endpoints (no traffic), &lt;strong&gt;not&lt;/strong&gt; restarted. This is the right place to check PostgreSQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;startup&lt;/strong&gt; gates the other two — until it passes, liveness/readiness are disabled. Use it for slow starters instead of a big &lt;code&gt;initialDelaySeconds&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;QoS&lt;/strong&gt;: Guaranteed (request=limit everywhere) evicted last; BestEffort (no requests/limits) evicted first. Always set at least requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful shutdown&lt;/strong&gt;: on delete, the Pod goes Terminating, &lt;code&gt;preStop&lt;/code&gt; runs, kubelet sends &lt;strong&gt;SIGTERM&lt;/strong&gt; to PID 1, waits &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; (default 30s), then SIGKILL. Your app must catch SIGTERM — and uvicorn must be PID 1 (exec-form CMD, no shell wrapper) or the signal never arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoints race&lt;/strong&gt;: removing the Pod from endpoints and sending SIGTERM happen in parallel, so traffic can still hit a shutting-down Pod (5xx during deploy). Fix: a &lt;code&gt;preStop: sleep 15&lt;/code&gt; so routing updates first. Keep preStop well under the grace period — it counts against the same budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-downtime rollout&lt;/strong&gt;: &lt;code&gt;maxUnavailable: 0&lt;/code&gt;, &lt;code&gt;maxSurge: 1&lt;/code&gt;, plus &lt;code&gt;minReadySeconds&lt;/code&gt;. Without a readiness probe, zero-downtime is impossible — the rollout waits on readiness before retiring old Pods.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify locally: &lt;code&gt;kubectl get endpointslices -n myapp -w&lt;/code&gt; while running a &lt;code&gt;while true; do curl ...&lt;/code&gt; loop during a rollout.&lt;/p&gt;

&lt;p&gt;Full article: &lt;a href="https://dorokhovich.com/blog/local-k8s-making-it-production-like?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-making-it-production-like" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-making-it-production-like?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-making-it-production-like&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>k3d</category>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 13: Making your local setup truly production-like</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:23:06 +0000</pubDate>
      <link>https://dev.to/mtrnm/local-kubernetes-dev-part-13-making-your-local-setup-truly-production-like-4gka</link>
      <guid>https://dev.to/mtrnm/local-kubernetes-dev-part-13-making-your-local-setup-truly-production-like-4gka</guid>
      <description>&lt;p&gt;"Works on my laptop" ≠ "survives in prod." And you can verify almost all of that difference locally in k3d.&lt;/p&gt;

&lt;p&gt;Probe behavior, restarts from liveness, how a rolling update proceeds, how the app shuts down on a signal — k3d reproduces all of it like a real cluster. Which means the traps get caught at your desk, not in prod on a Friday evening.&lt;/p&gt;

&lt;p&gt;In the new article (part 13) we add, one at a time, the things that make a service resilient to the Deployment:&lt;br&gt;
• requests/limits and the key distinction: CPU is throttled when exceeded (slows down), but memory is NOT compressible — the process gets killed, that's OOMKilled with code 137;&lt;br&gt;
• the three probes and who does what: liveness restarts a hung container, readiness pulls the Pod out of endpoints (no restart, DB checks go here too), startup gives a slow start room to come up;&lt;br&gt;
• QoS classes and who gets evicted first (BestEffort);&lt;br&gt;
• graceful shutdown: why the app must catch SIGTERM, the race with endpoints, and the cure — a preStop sleep;&lt;br&gt;
• zero-downtime rollout: maxUnavailable: 0 + maxSurge: 1, and why zero downtime is fundamentally impossible without readiness.&lt;/p&gt;

&lt;p&gt;At the end — a prod-readiness checklist. Read it: &lt;a href="https://dorokhovich.com/blog/local-k8s-making-it-production-like?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-making-it-production-like" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-making-it-production-like?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-making-it-production-like&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>k3d</category>
    </item>
    <item>
      <title>events -&gt; describe -&gt; logs: the Kubernetes debugging chain (and the --previous flag you're not using)</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Sat, 05 Sep 2026 12:30:23 +0000</pubDate>
      <link>https://dev.to/mtrnm/events-describe-logs-the-kubernetes-debugging-chain-and-the-previous-flag-youre-not-5eka</link>
      <guid>https://dev.to/mtrnm/events-describe-logs-the-kubernetes-debugging-chain-and-the-previous-flag-youre-not-5eka</guid>
      <description>&lt;p&gt;Almost every Kubernetes debugging session follows the same route. Memorize it: &lt;strong&gt;events -&amp;gt; describe -&amp;gt; logs&lt;/strong&gt;. First see what happened at the cluster level, then the details of a specific Pod, then what the app itself said.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl logs --previous&lt;/code&gt; (&lt;code&gt;-p&lt;/code&gt;) is the key to CrashLoopBackOff.&lt;/strong&gt; The current container just restarted and has written nothing (or crashed again), so plain &lt;code&gt;kubectl logs&lt;/code&gt; shows nothing useful. &lt;code&gt;-p&lt;/code&gt; pulls the logs of the &lt;em&gt;previous, already-dead&lt;/em&gt; instance — that's where the real stack trace lives. Note: &lt;code&gt;-f&lt;/code&gt; and &lt;code&gt;-p&lt;/code&gt; don't combine (you can't stream something already dead).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl describe pod&lt;/code&gt; → read the Events block bottom-up.&lt;/strong&gt; Look for &lt;code&gt;Warning&lt;/code&gt; with reasons like &lt;code&gt;Failed&lt;/code&gt;, &lt;code&gt;BackOff&lt;/code&gt;, &lt;code&gt;FailedScheduling&lt;/code&gt;. describe tells you &lt;em&gt;what&lt;/em&gt; happened at the k8s level ("container crashing, so BackOff"); logs &lt;code&gt;-p&lt;/code&gt; tells you &lt;em&gt;why&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CrashLoopBackOff and ImagePullBackOff are NOT Pod phases.&lt;/strong&gt; The five real phases are Pending, Running, Succeeded, Failed, Unknown. Those &lt;code&gt;*BackOff&lt;/code&gt; strings are display fields kubectl synthesizes from container state — the underlying phase often stays &lt;code&gt;Pending&lt;/code&gt;. Confusing the two makes the docs harder to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl get events&lt;/code&gt; isn't sorted by time by default&lt;/strong&gt; — add &lt;code&gt;--sort-by='.lastTimestamp'&lt;/code&gt;. Events also have a ~1h TTL, so yesterday's incident is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check from inside&lt;/strong&gt; with &lt;code&gt;port-forward&lt;/code&gt; (tunnel via the API server) and &lt;code&gt;exec&lt;/code&gt;. No shell (distroless)? Use &lt;code&gt;kubectl debug&lt;/code&gt; to attach an ephemeral container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;k9s&lt;/strong&gt; is a terminal UI over the same kubectl actions — fast, but understand the commands first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't over-build.&lt;/strong&gt; For local dev, logs + describe + events + k9s are enough. A full Prometheus/Grafana/OTel stack is a &lt;em&gt;direction to grow&lt;/em&gt;, not a local requirement. Mnemonic: metrics = what, logs = details, traces = why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full article: &lt;a href="https://dorokhovich.com/blog/local-k8s-debugging-and-observability?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-debugging-and-observability" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-debugging-and-observability?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-debugging-and-observability&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>debugging</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 12: Debugging and observability</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Wed, 02 Sep 2026 11:09:18 +0000</pubDate>
      <link>https://dev.to/mtrnm/local-kubernetes-dev-part-12-debugging-and-observability-nj3</link>
      <guid>https://dev.to/mtrnm/local-kubernetes-dev-part-12-debugging-and-observability-nj3</guid>
      <description>&lt;p&gt;"Pod's in CrashLoopBackOff, but kubectl logs is empty" — sound familiar?&lt;/p&gt;

&lt;p&gt;The logs are empty because you're looking at the freshly restarted container, which hasn't written anything yet. The real cause of the crash is in the logs of the previous, already-dead instance. And you pull them with one flag many people don't know about:&lt;/p&gt;

&lt;p&gt;kubectl logs -p  -n myapp&lt;/p&gt;

&lt;p&gt;In the new article (part 12) I've collected the essential debugging toolkit — and one idea worth remembering forever: almost any problem in Kubernetes unwinds along the chain events → describe → logs.&lt;/p&gt;

&lt;p&gt;I cover:&lt;br&gt;
• kubectl logs with -f, --previous, --since, --tail and the multi-container trap;&lt;br&gt;
• kubectl describe pod and reading the Events block from the bottom up;&lt;br&gt;
• why CrashLoopBackOff and ImagePullBackOff are NOT pod phases (the phase stays Pending) but status fields assembled by kubectl;&lt;br&gt;
• port-forward and exec to check the app from the inside (and kubectl debug for distroless with no shell);&lt;br&gt;
• k9s — the whole cluster in one window;&lt;br&gt;
• the three pillars of observability and an honest warning: don't drag a full Prometheus stack into local.&lt;/p&gt;

&lt;p&gt;Read it and stop fearing red statuses: &lt;a href="https://dorokhovich.com/blog/local-k8s-debugging-and-observability?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-debugging-and-observability" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-debugging-and-observability?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-debugging-and-observability&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>debugging</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The third level of failure — when the tool says ok and lies</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Thu, 27 Aug 2026 20:42:20 +0000</pubDate>
      <link>https://dev.to/mtrnm/the-third-level-of-failure-when-the-tool-says-ok-and-lies-284b</link>
      <guid>https://dev.to/mtrnm/the-third-level-of-failure-when-the-tool-says-ok-and-lies-284b</guid>
      <description>&lt;p&gt;MCP defines two ways for a tool call to fail. There is a third, no protocol can catch it, and it cost me twelve articles.&lt;/p&gt;

&lt;p&gt;On 25 July 2026 I audited every entry in my publishing calendar that carried a published URL — twenty-five of them. Twelve live posts on Medium were truncated: between 41% and 72% of the original text had survived, and some had lost their code blocks entirely. A thirteenth URL answered with HTTP 410. Every one had been reported as a success, with a valid URL, weeks earlier.&lt;/p&gt;

&lt;p&gt;Here is the mechanism. My publishing server puts drafts on Medium by driving a signed-in browser, because Medium's write API has been left to rot. The adapter waited for the address bar to change to the new draft's edit URL, treated that as done, and closed the tab. Medium, meanwhile, saves the editor's contents incrementally, over background requests that had not finished. Everything that had not reached the server by the time the tab closed was lost, silently, and the adapter returned &lt;code&gt;ok&lt;/code&gt;. There was no failed request to catch: the page that would have made it no longer existed.&lt;/p&gt;

&lt;p&gt;Now the part I got wrong. For weeks I read this as "browser automation is unreliable and APIs are reliable". That is not what the data says. dev.to came through completely intact — and so did two other platforms that I drive through exactly the same browser, over exactly the same debugging protocol, in the same run. The dividing line was never browser against API. It was one submission against many: those platforms hand over the whole text in a single POST with nothing left to arrive afterwards, while Medium accepted it in instalments and my adapter stopped watching after the first one. The same fact explains the survivors: short announcements of around 1,500 characters finished saving before the tab closed. It was a race, and short texts won it.&lt;/p&gt;

&lt;p&gt;And the reason nobody noticed for weeks is the most ordinary reason there is. The check that existed asked whether the URL was alive. It was. A truncated article is a perfectly healthy web page: 200, renders, title, hero image, three paragraphs, and then it simply stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug, which is better than the first
&lt;/h2&gt;

&lt;p&gt;The adapter also had to notice when Medium's editor failed to save, so it searched the page text for the phrases Medium shows on failure — one of which is "something is wrong". It searched &lt;code&gt;document.body.innerText&lt;/code&gt;, which on that page includes the article being published.&lt;/p&gt;

&lt;p&gt;One of the articles contained the sentence "if something is wrong you flip back".&lt;/p&gt;

&lt;p&gt;A successful run was declared a failure, the retry ran, and a duplicate draft appeared. The rule generalises well past browsers: never read status out of prose. If the only way to learn an outcome is to hunt for words in running text, you have guessed it rather than learned it, and prose will eventually contain your keyword by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the protocol cannot help
&lt;/h2&gt;

&lt;p&gt;MCP's two levels are a good pair. A protocol error means nothing ran — no such tool, arguments that failed the schema. An execution error means the tool ran and the news is bad, and it comes back as a successful response carrying a flag, in text, because the recipient is a model in the middle of a task and can act on it.&lt;/p&gt;

&lt;p&gt;Neither level sees this. From the protocol's point of view the call went perfectly: request sent, well-formed response, no error flag, a URL inside. The flag that reports failure is set by the same code that was wrong about what happened. A protocol can carry a verdict; it cannot audit one.&lt;/p&gt;

&lt;p&gt;There is a bill attached, too. On that request the model paid for twenty tool descriptions, paid again for the call, received &lt;code&gt;ok&lt;/code&gt;, and moved on. The spend was real. The work was not done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What replaced the check
&lt;/h2&gt;

&lt;p&gt;The check that the work happened has to be separate from the tool that did it, and it has to ask a different question: not "did the call return" but "is what is now on the server the same as what I sent".&lt;/p&gt;

&lt;p&gt;Mine reads every published article back and diffs it against the source, rendering each URL in the signed-in browser that owns it, because Medium blocks anonymous fetches and drafts are private to their account. It compares two signals: the number of code blocks, and whether the text of the last link is present — in these articles the last link sits in the closing "further reading" block, which makes it the first casualty of truncation. It has a second mode that opens everything in a profile signed in to nothing, because an author always sees his own article in full, so a paywall or a login wall is invisible from the account that published it.&lt;/p&gt;

&lt;p&gt;The full audit table, the script, and the fix that now refuses to return a URL if less than 98% of the text landed on the server are on my own site: &lt;a href="https://dorokhovich.com/blog/mcp-false-success" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/mcp-false-success&lt;/a&gt; — and the third article of the series, on what an MCP server is made of, is at &lt;a href="https://dorokhovich.com/blog/how-mcp-works" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/how-mcp-works&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source: my own publishing server and its runbook, audited 25 July 2026. One server, one platform, one bug, a sample of twenty-five. It is not evidence about AI agents in general and I have no money figure to give you, because I never calculated one.&lt;/p&gt;

&lt;p&gt;One real question, and I want the disagreement rather than the agreement: does your post-deploy check ask whether the thing is reachable, or whether it is correct? If you think the distinction does not apply to what you run, tell me what your check compares against, because mine compared against nothing and looked green for weeks.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>api</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
    <item>
      <title>Every MCP revision, and what each one took back</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Thu, 20 Aug 2026 08:55:34 +0000</pubDate>
      <link>https://dev.to/mtrnm/every-mcp-revision-and-what-each-one-took-back-5fm5</link>
      <guid>https://dev.to/mtrnm/every-mcp-revision-and-what-each-one-took-back-5fm5</guid>
      <description>&lt;p&gt;MCP is twenty months old and has had five specification revisions. I put them all in one table because the shape of the list says more than any single entry does.&lt;/p&gt;

&lt;p&gt;Read the removals, not the additions. Batching arrived in March 2025 and was gone by June, before it was three months old, which tells you it was never load-bearing: nothing had been built on it, so the specification was free to admit the mistake. Sessions and the opening handshake went on 28 July 2026, and that is a different kind of news entirely. A capability deleted in month seven is a correction. A rework of the foundations in month twenty means the foundations were wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Revision&lt;/th&gt;
&lt;th&gt;Added&lt;/th&gt;
&lt;th&gt;Taken back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2024-11-05&lt;/td&gt;
&lt;td&gt;JSON-RPC; stdio and HTTP+SSE; tools, resources, prompts&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-03-26&lt;/td&gt;
&lt;td&gt;OAuth 2.1; Streamable HTTP; batching&lt;/td&gt;
&lt;td&gt;HTTP+SSE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-06-18&lt;/td&gt;
&lt;td&gt;Structured output&lt;/td&gt;
&lt;td&gt;Batching, under three months old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025-11-25&lt;/td&gt;
&lt;td&gt;Icons; schema defaults; JSON Schema 2020-12&lt;/td&gt;
&lt;td&gt;Nothing the revision list records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-07-28&lt;/td&gt;
&lt;td&gt;Capability-listing call; "input required"; response cache hints; routing headers&lt;/td&gt;
&lt;td&gt;Sessions; the handshake; stream resumption. Deprecated with a year of grace: server-to-model calls, folder access, protocol-level logging, dynamic client registration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 2026-07-28 row is the one worth arguing about. Removing sessions made MCP stateless, which is Fielding's second REST constraint, adopted twenty months late by a protocol that had started from the opposite premise. Everything else in that revision follows from it: response cache hints, and headers carrying enough routing information that a proxy can direct a request without parsing the body. These are the features you add when your protocol has to survive contact with ordinary infrastructure.&lt;/p&gt;

&lt;p&gt;My reading, and you are welcome to disagree with it: the reversal is late and right. Sessions in a protocol living between two processes owned by different people were a mistake from the beginning. The two ends are deployed, restarted, scaled and upgraded on separate schedules, and a shared memory between them is a liability one side always ends up holding.&lt;/p&gt;

&lt;p&gt;Nothing broke on 28 July. The revision came out as a Release Candidate, the SDKs are in beta, and everything deprecated has a year of grace. The practical consequence is not "wait", it is "budget for rewriting".&lt;/p&gt;

&lt;p&gt;Source: modelcontextprotocol.io, revisions 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 and 2026-07-28, plus the official changelog for the last of them. I keep this table current as revisions land, with per-revision anchors so you can link to a row rather than to a page: &lt;a href="https://dorokhovich.com/blog/mcp-revision-history" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/mcp-revision-history&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One real question: is there a row here you would have filled in differently? The 2025-11-25 line in particular says "nothing the revision list records", and that is a claim about a changelog rather than a line-by-line diff. If you have diffed it, tell me what I missed.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>api</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
    <item>
      <title>Hacker News + dev.to (a concrete '2-5 minutes to a few seconds' DX win is a proven front-page hook)</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:59:47 +0000</pubDate>
      <link>https://dev.to/mtrnm/hacker-news-devto-a-concrete-2-5-minutes-to-a-few-seconds-dx-win-is-a-proven-front-page-hook-1dk9</link>
      <guid>https://dev.to/mtrnm/hacker-news-devto-a-concrete-2-5-minutes-to-a-few-seconds-dx-win-is-a-proven-front-page-hook-1dk9</guid>
      <description>&lt;h1&gt;
  
  
  We got the Kubernetes inner loop from 2–5 minutes back to seconds with Tilt
&lt;/h1&gt;

&lt;p&gt;Chapter 8 of a local-Kubernetes series — the payoff that closes the loop chapter 1 opened. Tilt watches files, rebuilds, deploys, and with Live Update syncs code into a &lt;em&gt;running&lt;/em&gt; container in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pain, quantified:&lt;/strong&gt; by hand, every edit is &lt;code&gt;docker build → push → kubectl rollout restart → wait → check logs&lt;/code&gt; — a minute or two each. Ten edits = half an hour gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Tiltfile is a program&lt;/strong&gt;, not YAML — it's Starlark (a Python dialect). &lt;code&gt;tilt up&lt;/code&gt; executes it top to bottom; edit the Tiltfile and Tilt re-executes it, nothing to restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The three pillars:&lt;/strong&gt; &lt;code&gt;docker_build('tag','ctx')&lt;/code&gt; (how to build), &lt;code&gt;k8s_yaml('file')&lt;/code&gt; (what to deploy), &lt;code&gt;k8s_resource('name', port_forwards=...)&lt;/code&gt; (fine-tuning). Tilt matches built images to manifest images &lt;strong&gt;by tag&lt;/strong&gt; and swaps in the fresh build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Update is the star:&lt;/strong&gt; instead of rebuild+redeploy, Tilt copies changed files straight into the running container. Steps run in a strict order — &lt;code&gt;fall_back_on&lt;/code&gt; → &lt;code&gt;sync&lt;/code&gt; → &lt;code&gt;run&lt;/code&gt; → &lt;code&gt;restart_container&lt;/code&gt; — and the order can't be broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two newbie traps:&lt;/strong&gt; &lt;code&gt;sync&lt;/code&gt; paths must live inside the same &lt;code&gt;docker_build&lt;/code&gt; context ("if Tilt is watching it, you can sync it"); &lt;code&gt;run()&lt;/code&gt; can't come before &lt;code&gt;sync()&lt;/code&gt;. Also: the &lt;em&gt;first&lt;/em&gt; deploy is always full — Live Update needs a running container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hot reload, two scenarios:&lt;/strong&gt; (A) framework reloads itself — &lt;code&gt;uvicorn --reload --reload-dir app&lt;/code&gt;, &lt;code&gt;sync&lt;/code&gt; is enough; (B) no reload — use the &lt;code&gt;restart_process&lt;/code&gt; extension (&lt;code&gt;docker_build_with_restart&lt;/code&gt;), the modern replacement for the deprecated &lt;code&gt;restart_container()&lt;/code&gt; on k8s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard at &lt;code&gt;localhost:10350&lt;/code&gt;:&lt;/strong&gt; per-resource update + runtime status, logs with filtering, endpoints as links, manual Trigger Update, optional &lt;code&gt;TRIGGER_MODE_MANUAL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;vs Skaffold/DevSpace:&lt;/strong&gt; Tilt = UI-first + Starlark + Live Update; Skaffold/DevSpace = CLI-first + YAML. Fair caveat: pick by team taste.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full article: &lt;a href="https://dorokhovich.com/blog/local-k8s-tilt-fast-dev-loop?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-tilt-fast-dev-loop" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-tilt-fast-dev-loop?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-tilt-fast-dev-loop&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Local Kubernetes Dev — Part 8: Tilt — a fast development loop</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:41:15 +0000</pubDate>
      <link>https://dev.to/mtrnm/local-kubernetes-dev-part-8-tilt-a-fast-development-loop-6dp</link>
      <guid>https://dev.to/mtrnm/local-kubernetes-dev-part-8-tilt-a-fast-development-loop-6dp</guid>
      <description>&lt;p&gt;Remember the loop from chapter one that stretched from seconds to 2–5 minutes on every change? This is the chapter where we bring it back down to seconds.&lt;/p&gt;

&lt;p&gt;Part eight of the series is the emotional climax. Tilt orchestrates your inner loop: it watches files, builds the image for you, deploys to the cluster for you, and — crucially — can update code right inside the running container in seconds. Plus a web dashboard where all your services, statuses, and logs live on one screen, no juggling ten terminals.&lt;/p&gt;

&lt;p&gt;The three pillars of any Tiltfile: &lt;code&gt;docker_build&lt;/code&gt; (how to build), &lt;code&gt;k8s_yaml&lt;/code&gt; (what to deploy), &lt;code&gt;k8s_resource&lt;/code&gt; (fine-tuning — port forwarding). The magic is that Tilt matches images from docker_build to images in the manifests by tag and substitutes the fresh build. The star of the show is Live Update: instead of "rebuild image → redeploy pod," Tilt copies the changed files straight into the live container. With FastAPI it pairs beautifully with &lt;code&gt;uvicorn --reload&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I break down the strict order of live_update steps (fall_back_on → sync → run → restart), two beginner traps (sync only within the build context; run can't come before sync), the no-hot-reload path via restart_process, and an honest comparison of Tilt vs Skaffold vs DevSpace. &lt;a href="https://dorokhovich.com/blog/local-k8s-tilt-fast-dev-loop?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-tilt-fast-dev-loop" rel="noopener noreferrer"&gt;https://dorokhovich.com/blog/local-k8s-tilt-fast-dev-loop?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=local-k8s-tilt-fast-dev-loop&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>What GitHub's MCP Server Costs You Before You Ask It Anything</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:02:21 +0000</pubDate>
      <link>https://dev.to/mtrnm/sindikatsiia-devto-mcp-vs-rest-the-bill-comes-in-tokens-2agb</link>
      <guid>https://dev.to/mtrnm/sindikatsiia-devto-mcp-vs-rest-the-bill-comes-in-tokens-2agb</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;br&gt;
GitHub's official MCP server spends roughly 17,600 tokens on tool descriptions before you ask it anything, with every toolset switched on.&lt;br&gt;
The same operation through MCP costs 4 to 32 times what the equivalent command-line call costs.&lt;br&gt;
If you can write the list of calls in advance, write it, and keep REST underneath.&lt;/p&gt;

&lt;p&gt;Attach GitHub's official MCP server to an assistant, every toolset switched on, and the conversation opens with a bill of roughly 17,600 tokens. Nobody has asked anything yet. That is the cost of the tool descriptions alone — the names, the sentence explaining what each tool does, the shape of the arguments it expects — and it is paid again on every request for as long as the server stays attached. The figure is a count of the server's own tool schemas, published by people who counted them; it is not a vendor's number.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Written 5 August 2026.&lt;/strong&gt; Every figure below was measured between late 2025 and July 2026, before the revision of 28 July changed some of the rules they were measured under. The prices move; the arithmetic does not. Measure on your own workload before you plan around them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to be careful with that number, because it belongs to a configuration rather than to a product. The server groups its tools into toolsets you can switch off, and switching the unused ones off cuts the figure several times over. Which is exactly the point: nobody can honestly quote it without saying which toolsets were on. In the measurement above, all of them were.&lt;/p&gt;

&lt;p&gt;The "MCP versus REST" argument is almost always conducted as though it were a matter of taste, settled by whichever side sounds more modern. It is not a matter of taste. There is a price list, the numbers on it are fairly harsh, and once you have seen them the choice stops being about architecture and starts being about whether the thing you are building actually needs a model to decide anything.&lt;/p&gt;

&lt;p&gt;Where the numbers come from, before anyone asks in the comments: public analyses of the GitHub MCP Server and survey write-ups of context overhead from late 2025 and early 2026, plus the schema-overhead discussion in issue #2808 of the specification repository. Specification revisions used: 2025-06-18 and 2026-07-28. The truncation incident later in this piece is from my own publishing server, audited on 25 July 2026 — that part is not a citation, it is my own damage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the bill
&lt;/h2&gt;

&lt;p&gt;The bill is denominated in two things, and only one of them is money. A token is the unit a language model is billed in and bounded by. The model here is any LLM you like; the brand changes nothing about the unit. It is a fragment of text — roughly three quarters of a word in English, rather less in Russian, where the same sentence costs more tokens to say. The bound is the context window: the fixed amount of text a model can hold in view at one time, covering the conversation, whatever you pasted into it, and everything the tools have said about themselves. Every number below is a share of that window, spent before the model does anything you would recognise as work.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A model does not "connect" to a tool the way a program connects to a database. It is handed a written description of every available tool, in full, at the start of every request, and it reads that description again each time&lt;/li&gt;
&lt;li&gt;GitHub's official MCP server: roughly 17,600 tokens of tool descriptions with the full toolset enabled, before a question is asked. That number belongs to a configuration rather than to the product — the server groups its tools into toolsets that can be switched off, and switching the unused ones off cuts the figure several times over. Which is the point: the number cannot honestly be quoted without saying which toolsets were on&lt;/li&gt;
&lt;li&gt;Attach several servers at once — an issue tracker, a browser, a database — and 30,000 tokens and up is ordinary&lt;/li&gt;
&lt;li&gt;Estimates from early 2026 put a typical desktop assistant at 40–50% of its context window consumed before the first question. Percentages are the wrong unit here, and I quote them only because that is how the estimates were published. They were published without the configurations they were measured on, which is exactly the thing I have just said makes a number unquotable. The tokens are the fact: 17,600 for one server with everything enabled, past 30,000 once several are attached. A larger context window makes the same descriptions look cheaper without making them cheaper — the share falls, the count does not move, and you pay it on every request&lt;/li&gt;
&lt;li&gt;The comparison that sobers people up: the same operation performed through MCP costs 4 to 32 times more tokens than the equivalent command-line call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four to 32 times is not overhead. Overhead is a percentage. This is a different order of magnitude, and it deserves to be said plainly: for any task you can solve with a script, MCP is not worth paying for. A nightly job that reads a file and posts it to an endpoint needs no tool descriptions, no model reading them, and no bill. The price starts buying you something at exactly the point where you can no longer write the list of calls in advance.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F85smuhj9nfl8ea30a29r.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F85smuhj9nfl8ea30a29r.png" alt="The cost in tokens" width="800" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost that appears on no invoice
&lt;/h2&gt;

&lt;p&gt;The costs so far can be counted before you spend them. This one cannot, and it appears on no invoice at all. I paid it personally. I run an MCP server with twenty tools that manages my publication calendar and puts articles on Medium, dev.to, Stack Exchange, Hacker News and Telegram. Its Medium adapter drives a signed-in Chrome over the debugging protocol, because Medium's write API has been left to rot. The adapter considered its work finished the moment the browser's address changed to the editing URL of a new draft, and then closed the tab. Medium, meanwhile, saves the editor's contents incrementally, over background requests that had not yet finished. Everything that had not reached the server by the time the tab closed was lost, silently. The adapter returned a valid URL and a success.&lt;/p&gt;

&lt;p&gt;The audit on 25 July 2026 covered all 25 calendar entries that carried a published URL. Twelve live posts on Medium were truncated, retaining between 41% and 72% of their text, and some had lost code blocks. A thirteenth URL answered with HTTP 410. Short announcements of around 1,500 characters had survived, because they finished saving before the tab closed. Nothing else was affected: dev.to, Stack Overflow and Hacker News all came through intact. That is the detail worth pausing on, because Stack Overflow and Hacker News are driven through a browser exactly as Medium is. The dividing line was never browser against API. It was one submission against many: those three hand over the whole text in a single POST, while Medium accepted it in instalments and my adapter stopped watching after the first one.&lt;/p&gt;

&lt;p&gt;The reason it went unnoticed for weeks is the ordinary reason. The check that existed asked whether the URL was alive, not whether the article was all there. The post loads. The link works. Only the second half is missing.&lt;/p&gt;

&lt;p&gt;Two things follow, and neither of them is about Medium. The first is that a tool returning success has told you nothing about whether the work happened. &lt;a href="https://dorokhovich.com/blog/how-mcp-works" rel="noopener noreferrer"&gt;MCP distinguishes two levels of failure&lt;/a&gt; — the protocol broke, or the tool ran and reported bad news — and this is a third kind that neither level can see, because from the protocol's point of view the call went perfectly. The second is about tokens. On that request the model paid for twenty tool descriptions, paid again for the call, received ok, and moved on to the next item in its plan. The spend was real. The work was not done. Anonymous statistics about tool calls that never complete get quoted a lot and sourced almost never. I cannot tell you what is behind them. I can tell you what was behind mine, and that nothing will tell you when it is happening to you.&lt;/p&gt;

&lt;p&gt;A second bug sat beside the first, and it cost a duplicate draft rather than a truncated article: the adapter decided whether Medium had failed to save by looking for failure phrases in the page text, and one of the articles being published contained one of the phrases. &lt;a href="https://dorokhovich.com/blog/how-mcp-works" rel="noopener noreferrer"&gt;The rule that comes out of that is in the third article&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just add more tools" makes things worse
&lt;/h2&gt;

&lt;p&gt;A task that ran reliably with ten tools starts coming apart at fifty. The instinct is to blame volume — more text, a fuller window, worse answers — and volume is the smaller half of the problem. The real problem is selection. The model is choosing one item from a list, and every item it did not pick is a distraction it had to read and rule out. Two tools whose descriptions differ by a single clause are not two options; they are a coin toss the model performs on your behalf, once per call, silently. Long lists also encourage the model to plan in tool calls rather than in steps, which is how a job that needed three calls turns into eleven.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool responses cost more than tool descriptions
&lt;/h3&gt;

&lt;p&gt;The second cost gets discussed less and is frequently larger than the first: the responses. Descriptions are a fixed charge, paid per request. Responses are metered. A call that lists pull requests returns author objects, timestamps, avatar URLs and review metadata that nobody asked for, and all of it lands in the same window as the descriptions. It is entirely normal for a session to spend more context on what the tools said back than on the catalogue of what they could do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where REST is still the better answer
&lt;/h2&gt;

&lt;p&gt;Everything in that publishing server except the browser adapters is a plain HTTP call to a REST API, and that half has never lost anything. REST did not get worse when models arrived. It stayed exactly as good as it was, and the cases it was good at are still the majority of cases.&lt;/p&gt;

&lt;p&gt;Keep it wherever the calls are known in advance and made by a program rather than chosen by a model. If you can enumerate the calls while writing the code, enumerate them: you get a compiler, a code review, a stack trace and a test suite, none of which a model's choice of tool will ever give you. High volumes of similar requests are the other obvious case — a quarter of a century of HTTP caching, ETag, CDNs, load balancers and per-key limits are sitting there, built and debugged by other people, and none of it knows what an MCP server is. A public interface has its own reason to stay a REST API: outside developers want stability, versioning and an OpenAPI description they read once at integration time, not flexibility to be rediscovered on every call. And there is cost. A REST call has a number you can put in a spreadsheet; an MCP call costs whatever the user happened to have attached that day.&lt;/p&gt;

&lt;p&gt;The simple rule holds up better than any diagram: if you can write the list of calls in advance, write it. An MCP server sitting inside a scheduled job, with no human in the loop and nothing for a model to decide, is a very expensive HTTP client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where MCP earns its keep
&lt;/h2&gt;

&lt;p&gt;The price buys one thing, and it is the thing that makes an AI agent an agent rather than a script: the ability not to know, at the time you write the program, what the program will be asked to do. Where that ignorance is genuine, the bill is fair. Where it is a habit of mind, you are paying to avoid making a decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork, in practice
&lt;/h2&gt;

&lt;p&gt;Two questions decide it, and they have to be asked in order.&lt;/p&gt;

&lt;p&gt;The first is who makes the call: a programmer, or a model? If a programmer, the argument is over before it starts. You have documentation, types, a compiler and a review process, and none of the machinery MCP exists to provide is any use to you. Make the HTTP call.&lt;/p&gt;

&lt;p&gt;The second question only applies if the answer to the first was "a model": is the list of actions known when you ship? If it is known, small and fixed — three actions, always the same three — then function calling against your own API will get you most of the way there. You describe the three functions to the model directly, in the request, and skip the protocol between the parts entirely. Model Context Protocol starts paying for itself when the set is genuinely open: when the user attaches what they need, when a server you have never seen announces its tools at runtime, when the next step depends on what the last one returned in a way you cannot enumerate ahead of time.&lt;/p&gt;

&lt;p&gt;In practice the answer is almost always "both", and not as a diplomatic compromise. It is what the systems look like. The MCP server I run is a wrapper — five hundred lines of tool definitions, and about as much again in the adapters and verifiers behind them — sitting in front of the same admin API the web interface uses. The endpoints are identical; only the authentication differs — a session cookie for the browser, an API key for the server. Nothing in the calendar was redesigned to be MCP-shaped, and nothing needed to be. What the wrapper adds is exactly the set of things a model needs and a program does not: descriptions written to be read rather than parsed, a deliberately narrower set of actions than the API exposes, and a point where a human can look at what is about to be sent and say no.&lt;/p&gt;

&lt;p&gt;So the honest summary is MCP on the outside, REST underneath. It is worth stating the inverse too, because it is the mistake I see most often: if your design has MCP underneath as well — services calling each other over it, batch jobs speaking it, a scheduler holding a tool catalogue — you have bought the price without buying the reason.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flvyskqd9k1c6zsfud82s.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flvyskqd9k1c6zsfud82s.png" alt="The fork" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to cut the MCP token bill
&lt;/h2&gt;

&lt;p&gt;Four mechanisms exist, and one habit beats most of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tool search: moving the catalogue out of the request
&lt;/h3&gt;

&lt;p&gt;Tool search shipped in late 2025 as part of Anthropic's advanced tool-use release, and it moves the catalogue out of the request: the client keeps the full list, and the model is handed only the tools that match the task in front of it. The reduction in startup cost reported in that announcement is 95%. I do not believe that as a general rule. The figure is true for setups that had far too many tools attached to begin with, which means it measures somebody's earlier mistake rather than the effect of the mechanism. Run it against a considered set of fifteen tools and you will not see anything like 95%, because there was never 95% of waste there to remove.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code execution instead of chains of tool calls
&lt;/h3&gt;

&lt;p&gt;Executing code instead of chaining calls is the interesting one, and the only one of the four that attacks the larger cost. Instead of asking the model to make eleven calls and read eleven responses, you let it write a short program that makes the calls itself. The intermediate data — the four-thousand-line list it filtered down to three rows — never passes through the context window at all. Only the three rows do. Since responses are usually where the money goes, this is where the money comes back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sub-agents do not save tokens
&lt;/h3&gt;

&lt;p&gt;Sub-agents are worth stating carefully, because the claim is usually made backwards. Sub-agents do not reduce total token spend. They generally increase it: each one re-reads its own instructions and its own slice of the tool catalogue, and the coordinator pays again for the summaries coming back. What they protect is the coordinator's context, which stays clear of forty tool descriptions and thousand-line outputs it will never need. That is a real benefit and often worth the money. It is not a saving, and budgeting for it as one is how a bill doubles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cacheable tool lists (2026-07-28)
&lt;/h3&gt;

&lt;p&gt;Cacheable tool lists came with &lt;a href="https://dorokhovich.com/blog/mcp-vs-rest-api" rel="noopener noreferrer"&gt;the 2026-07-28 revision&lt;/a&gt;: a hint from the server saying, in effect, that the client need not ask for this again yet — a direct consequence of the protocol becoming stateless. Mechanical, modest, and most useful in exactly the setups where descriptions dominate the bill.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cheapest fix: fewer tools, shorter descriptions
&lt;/h3&gt;

&lt;p&gt;Then there is the discipline that requires no technology whatsoever, works today, and outperforms everything above: fewer tools, shorter descriptions, filtered responses. Fifteen tools with one-line descriptions, returning only the fields somebody actually reads, will beat every mechanism in this section, and you can do it this afternoon without waiting for a specification revision or an SDK. My own version of it is not a mechanism at all: a deliberately narrower set of tools than the API exposes, and three verifiers that exist only because the caller is a model.&lt;/p&gt;

&lt;p&gt;Two numbers are worth carrying out of this. 17,600 tokens on every request, for one server with everything switched on; and 4 to 32 times the cost of the equivalent command-line call. Neither is an argument against MCP. Both are an argument for knowing, before you attach anything, which decision you are paying for the right to postpone. If you already know the answer, you have your list of calls, and you should write it. (You will not find "MCP versus REST" speed benchmarks here. They measure implementations rather than protocols, and the number that comes out says more about the server somebody tested than about the choice in front of you.)&lt;/p&gt;

&lt;p&gt;This is the second of three articles. The first is about &lt;a href="https://dorokhovich.com/blog/mcp-vs-rest-api" rel="noopener noreferrer"&gt;where MCP and REST came from&lt;/a&gt; and why the July 2026 revision turned MCP back toward REST; the third is about &lt;a href="https://dorokhovich.com/blog/how-mcp-works" rel="noopener noreferrer"&gt;what an MCP server is actually made of&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;The three articles this comes from are on my own site: &lt;a href="https://dorokhovich.com/blog/mcp-vs-rest-api" rel="noopener noreferrer"&gt;where MCP and REST came from&lt;/a&gt;, this one on &lt;a href="https://dorokhovich.com/blog/mcp-token-cost" rel="noopener noreferrer"&gt;what MCP costs in tokens&lt;/a&gt;, and &lt;a href="https://dorokhovich.com/blog/how-mcp-works" rel="noopener noreferrer"&gt;what an MCP server is actually made of&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One real question, and I want the disagreement rather than the agreement: have you measured what your own tool catalogue costs per request? Not the vendor's number for their server — yours, counted on your own descriptions. I have not published mine, and that is a fair thing to hold against this article: I run a server with twenty tools and I have quoted somebody else's figure rather than my own. If you have counted yours, I would like the number and the method, because the method is the part nobody publishes.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>api</category>
      <category>architecture</category>
      <category>ai</category>
    </item>
    <item>
      <title>Kubernetes ImagePullBackOff, CrashLoopBackOff, OOMKilled: The 3-Command Troubleshooting Playbook</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:32:08 +0000</pubDate>
      <link>https://dev.to/mtrnm/kubernetes-imagepullbackoff-crashloopbackoff-oomkilled-the-3-command-troubleshooting-playbook-5d0l</link>
      <guid>https://dev.to/mtrnm/kubernetes-imagepullbackoff-crashloopbackoff-oomkilled-the-3-command-troubleshooting-playbook-5d0l</guid>
      <description>&lt;h1&gt;
  
  
  Kubernetes ImagePullBackOff, CrashLoopBackOff, OOMKilled: The 3-Command Troubleshooting Playbook
&lt;/h1&gt;

&lt;p&gt;Most Kubernetes ImagePullBackOff / CrashLoopBackOff troubleshooting is not deep. The statuses read like spaceship error codes, but nearly all of them fall out of three commands and a short list of causes. This is the playbook I run on autopilot, plus the one k3d trap that cost me a morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The golden set
&lt;/h2&gt;

&lt;p&gt;Whatever the status, start here. Same &lt;code&gt;describe&lt;/code&gt; / &lt;code&gt;logs&lt;/code&gt; / &lt;code&gt;exec&lt;/code&gt; loop &lt;a href="https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/" rel="noopener noreferrer"&gt;the Kubernetes 'Debug Running Pods' guide&lt;/a&gt; recommends.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. State, Reason, and the Events block at the bottom — ALWAYS first&lt;/span&gt;
kubectl describe pod &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp

&lt;span class="c"&gt;# 2. App logs, and the CRASHED container's logs via --previous&lt;/span&gt;
kubectl logs &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp
kubectl logs &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--previous&lt;/span&gt;

&lt;span class="c"&gt;# 3. Namespace timeline&lt;/span&gt;
kubectl get events &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.lastTimestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;describe&lt;/code&gt; hands you three fields: &lt;code&gt;State&lt;/code&gt;, &lt;code&gt;Reason&lt;/code&gt;, and &lt;code&gt;Events&lt;/code&gt;. That's the loop. The rest is pattern-matching. There's a solid field guide to these same statuses at &lt;a href="https://dorokhovich.com/blog/local-k8s-troubleshooting?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-troubleshooting" rel="noopener noreferrer"&gt;this troubleshooting writeup&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  ImagePullBackOff — and the k3d containerd trap
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ErrImagePull&lt;/code&gt; first, then &lt;code&gt;ImagePullBackOff&lt;/code&gt; after backoff. Pod stuck &lt;code&gt;Waiting&lt;/code&gt;. Events line usually says it outright: &lt;code&gt;not found&lt;/code&gt; / &lt;code&gt;unauthorized&lt;/code&gt; / &lt;code&gt;no such host&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;The boring causes are enumerated in &lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;the Kubernetes images docs&lt;/a&gt;: typo in name or tag, private registry with no &lt;code&gt;imagePullSecrets&lt;/code&gt;, Docker Hub rate limit (&lt;code&gt;toomanyrequests&lt;/code&gt;), bad registry address.&lt;/p&gt;

&lt;p&gt;The trap that isn't boring: k3d nodes run on &lt;strong&gt;containerd, isolated from your Docker daemon&lt;/strong&gt;. The image you just built with &lt;code&gt;docker build&lt;/code&gt; — visible right there in &lt;code&gt;docker images&lt;/code&gt; — the cluster cannot see. Docker has it; containerd doesn't. &lt;code&gt;ImagePullBackOff&lt;/code&gt;, even though "the image is obviously built."&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Two correct ways to get the image into the cluster, both in &lt;a href="https://k3d.io/v5.7.5/usage/importing_images/" rel="noopener noreferrer"&gt;the k3d image-import docs&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Option 1: import into the nodes directly&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; myapp:dev &lt;span class="nb"&gt;.&lt;/span&gt;
k3d image import myapp:dev &lt;span class="nt"&gt;-c&lt;/span&gt; dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Option 2 (recommended): a local registry created with the cluster&lt;/span&gt;
k3d cluster create dev &lt;span class="nt"&gt;--registry-create&lt;/span&gt; k3d-registry.localhost:5000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# then the FULL name matters: address + port + tag&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k3d-registry.localhost:5000/myapp:dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic follow-up mistake is an incomplete image name — &lt;code&gt;myapp:dev&lt;/code&gt; instead of &lt;code&gt;k3d-registry.localhost:5000/myapp:dev&lt;/code&gt;. The cluster looks in the wrong place and you're back in backoff. If you use Tilt, &lt;code&gt;docker_build&lt;/code&gt; handles delivery, but the Tiltfile image name must match the manifest exactly or you get the same error.&lt;/p&gt;

&lt;h2&gt;
  
  
  CrashLoopBackOff — the status that hides its own cause
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;Container starts, crashes, kubelet restarts it, it crashes again — with exponential backoff, roughly 10s → 20s → 40s, capped at 5 minutes. The status itself tells you nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;The command I wish I'd known on day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--previous&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--previous&lt;/code&gt; you get the freshly-started container, which usually hasn't written anything. &lt;code&gt;--previous&lt;/code&gt; pulls the stack trace from the instance that actually died. Also read &lt;code&gt;Last State: Terminated&lt;/code&gt; and its &lt;code&gt;Exit Code&lt;/code&gt; in &lt;code&gt;describe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Causes I check in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Startup bug&lt;/strong&gt; — exception, non-zero exit. For a FastAPI service, missing &lt;code&gt;DB_HOST&lt;/code&gt; / &lt;code&gt;DB_PASSWORD&lt;/code&gt; crashes it on connect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing env/config&lt;/strong&gt; — a ConfigMap/Secret isn't mounted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency not up&lt;/strong&gt; — Postgres hasn't started and the app doesn't wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OOM&lt;/strong&gt; — out of memory (below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Liveness too strict&lt;/strong&gt; — kills the app before it warms up. Fix with &lt;code&gt;initialDelaySeconds&lt;/code&gt; or a &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;startup probe&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit code 0&lt;/strong&gt; — for a long-running service, "exited successfully" is still a crash loop. Usually a wrong entrypoint that runs and exits instead of starting uvicorn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the container dies instantly and logs are empty, keep it alive and climb inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# temporary, in the Deployment&lt;/span&gt;
&lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;infinity"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;span class="nb"&gt;env&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;DB_
uvicorn app.main:app &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="nt"&gt;--port&lt;/span&gt; 8080   &lt;span class="c"&gt;# run by hand, read the real error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pending — the scheduler couldn't place it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;Pod never leaves &lt;code&gt;Pending&lt;/code&gt;. &lt;code&gt;describe&lt;/code&gt; → Events → &lt;code&gt;FailedScheduling&lt;/code&gt;, usually naming the cause: &lt;code&gt;Insufficient cpu&lt;/code&gt; / &lt;code&gt;Insufficient memory&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;On a small local cluster this is the most common one. I once set &lt;code&gt;requests: 4&lt;/code&gt; CPUs on a 2-CPU k3d VM and the pod simply never scheduled. Other causes: nodeSelector/affinity mismatch, taints without tolerations, an unbound PVC, a taken &lt;code&gt;hostPort&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Lower &lt;code&gt;requests&lt;/code&gt;, fix the selector/PVC, or grow the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k3d node create extra &lt;span class="nt"&gt;--cluster&lt;/span&gt; dev &lt;span class="nt"&gt;--role&lt;/span&gt; agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a local FastAPI service, &lt;code&gt;100m&lt;/code&gt; CPU / &lt;code&gt;128Mi&lt;/code&gt; memory is fine. Don't paste production numbers into a laptop cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  OOMKilled — the exit-137 signature
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;Process blew past its memory limit and the kernel &lt;code&gt;SIGKILL&lt;/code&gt;'d it. Recognize it by exit code &lt;strong&gt;137&lt;/strong&gt; (128 + 9, SIGKILL) and &lt;code&gt;Reason: OOMKilled&lt;/code&gt; in &lt;code&gt;describe&lt;/code&gt;. That Reason field distinguishes a memory kill from other SIGKILLs, and OOM is often the hidden cause behind a CrashLoopBackOff.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause
&lt;/h3&gt;

&lt;p&gt;Two flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container-level&lt;/strong&gt; — exceeded &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/" rel="noopener noreferrer"&gt;&lt;code&gt;resources.limits.memory&lt;/code&gt;&lt;/a&gt;. Raise the limit or fix a leak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node-level&lt;/strong&gt; (sneaky on k3d) — the whole Docker VM ran out of memory, so pods get OOM-killed even while each app is within its own limit. The fix isn't Kubernetes limits; it's giving the Docker VM more memory, or not overcommitting — the sum of all pods' limits shouldn't exceed the VM's memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Service that's green but silent
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;Pods &lt;code&gt;Running&lt;/code&gt;, everything looks fine, requests never reach the app. Almost always one of three breaks in &lt;code&gt;Service → endpoints → Pod&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get endpointslices &lt;span class="nt"&gt;-n&lt;/span&gt; myapp &lt;span class="nt"&gt;-l&lt;/span&gt; kubernetes.io/service-name&lt;span class="o"&gt;=&lt;/span&gt;myapp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty means nothing is behind the Service. Three causes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Selector mismatch&lt;/strong&gt; — labels are case-sensitive, &lt;code&gt;app: Web&lt;/code&gt; ≠ &lt;code&gt;app: web&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port mismatch&lt;/strong&gt; — &lt;code&gt;targetPort&lt;/code&gt; must equal the real &lt;code&gt;containerPort&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readiness not passing&lt;/strong&gt; — pod shows &lt;code&gt;0/1 Ready&lt;/code&gt;, Kubernetes pulls it from endpoints so no traffic flows, but the container doesn't restart and still reads &lt;code&gt;Running&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Localize by hitting the pod directly, bypassing the Service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward &amp;lt;pod&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; myapp 8080:8080
curl http://localhost:8080/healthz   &lt;span class="c"&gt;# works? then it's selector/endpoints/readiness, not the app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Before / after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Old reflex&lt;/th&gt;
&lt;th&gt;The move&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ImagePullBackOff&lt;/td&gt;
&lt;td&gt;re-&lt;code&gt;docker build&lt;/code&gt;, panic&lt;/td&gt;
&lt;td&gt;check k3d containerd/registry gotcha; full image name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CrashLoopBackOff&lt;/td&gt;
&lt;td&gt;stare at empty logs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;logs --previous&lt;/code&gt; + exit code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pending&lt;/td&gt;
&lt;td&gt;assume cluster broken&lt;/td&gt;
&lt;td&gt;read &lt;code&gt;FailedScheduling&lt;/code&gt;; shrink requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OOMKilled&lt;/td&gt;
&lt;td&gt;confused restarts&lt;/td&gt;
&lt;td&gt;spot exit 137 + &lt;code&gt;Reason: OOMKilled&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service silent&lt;/td&gt;
&lt;td&gt;blame the app&lt;/td&gt;
&lt;td&gt;check endpoints/selector/readiness&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The whole thing is one algorithm: &lt;code&gt;get pods&lt;/code&gt; shows &lt;em&gt;what&lt;/em&gt; → &lt;code&gt;describe&lt;/code&gt; explains &lt;em&gt;why&lt;/em&gt; in &lt;code&gt;Reason&lt;/code&gt;/&lt;code&gt;Events&lt;/code&gt; → &lt;code&gt;logs --previous&lt;/code&gt; gives the crash detail. Ninety percent of cases are on the list above.&lt;/li&gt;
&lt;li&gt;Watch disk creep. Kubelet image GC is lazy — &lt;a href="https://kubernetes.io/docs/concepts/architecture/garbage-collection/" rel="noopener noreferrer"&gt;it won't clean until the node's disk crosses its high-threshold, 85% by default&lt;/a&gt; — so images pile up on purpose. &lt;code&gt;docker system df&lt;/code&gt; to see it, prune carefully; &lt;code&gt;docker system prune -a&lt;/code&gt; can delete images the cluster still needs.&lt;/li&gt;
&lt;li&gt;Keep the golden set in muscle memory. It's faster than any search bar.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Stop treating a red status as a mystery. Every one of them resolves to a &lt;code&gt;Reason&lt;/code&gt; field and a short cause list. The dread was never warranted — the statuses just have unfriendly names.&lt;/p&gt;

&lt;p&gt;Bottom line: memorize &lt;code&gt;describe&lt;/code&gt; + &lt;code&gt;logs --previous&lt;/code&gt; + &lt;code&gt;get events&lt;/code&gt;, learn the five common statuses, and remember that in k3d "the image is built" and "the cluster can see it" are two different facts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/" rel="noopener noreferrer"&gt;Kubernetes docs — Debug Running Pods (&lt;code&gt;describe&lt;/code&gt; / &lt;code&gt;logs&lt;/code&gt; / &lt;code&gt;exec&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;Kubernetes docs — Images (image pull, &lt;code&gt;imagePullPolicy&lt;/code&gt;, pull errors)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://k3d.io/v5.7.5/usage/importing_images/" rel="noopener noreferrer"&gt;k3d docs — Importing images (&lt;code&gt;k3d image import&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/assign-memory-resource/" rel="noopener noreferrer"&gt;Kubernetes docs — Assign Memory Resources (limits, OOMKilled)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;Kubernetes docs — Liveness, Readiness and Startup Probes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/architecture/garbage-collection/" rel="noopener noreferrer"&gt;Kubernetes docs — Node image garbage collection (disk thresholds)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-troubleshooting?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-troubleshooting" rel="noopener noreferrer"&gt;Full local-k8s troubleshooting playbook&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>docker-compose vs Kubernetes: The dev/prod Parity Gap That Ate Our First Rollout</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Tue, 11 Aug 2026 19:26:02 +0000</pubDate>
      <link>https://dev.to/mtrnm/docker-compose-vs-kubernetes-the-devprod-parity-gap-that-ate-our-first-rollout-4kko</link>
      <guid>https://dev.to/mtrnm/docker-compose-vs-kubernetes-the-devprod-parity-gap-that-ate-our-first-rollout-4kko</guid>
      <description>&lt;h1&gt;
  
  
  docker-compose vs Kubernetes: The dev/prod Parity Gap That Ate Our First Rollout
&lt;/h1&gt;

&lt;p&gt;The service passed every local test in &lt;code&gt;docker-compose&lt;/code&gt;. The first Kubernetes rollout fell over inside an hour on things Compose had never modeled: readiness, Secrets, startup order, service discovery. This is the docker-compose vs kubernetes dev/prod parity gap, and it cost us an outage before I admitted it existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;First prod rollout. Pod &lt;code&gt;Running&lt;/code&gt;, but the Service sent it zero traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api
&lt;span class="go"&gt;NAME                   READY   STATUS    RESTARTS   AGE
api-6c9f8b7d4-2xk9p    0/1     Running   3          2m

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl describe pod api-6c9f8b7d4-2xk9p | &lt;span class="nb"&gt;tail&lt;/span&gt;
&lt;span class="go"&gt;  Warning  Unhealthy  readiness probe failed: connection refused
&lt;/span&gt;&lt;span class="c"&gt;  ...
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;kubectl logs api-6c9f8b7d4-2xk9p &lt;span class="nt"&gt;--previous&lt;/span&gt;
&lt;span class="go"&gt;  sqlalchemy.exc.OperationalError: could not connect to server: Connection refused
  Is the server running on host "postgres" (10.43.x.x) and accepting connections?
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate failures, none of which any green Compose test had touched:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No readiness probe existed, so I'd never configured one — Compose has no such concept.&lt;/li&gt;
&lt;li&gt;A plaintext env var in Compose was supposed to be a Secret in k8s.&lt;/li&gt;
&lt;li&gt;The app crashed reaching the DB before the DB was up. Compose's &lt;code&gt;depends_on&lt;/code&gt; had papered over that for years.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;I'd assumed a Compose file and a set of k8s manifests were the same idea at different fidelity. They aren't. The &lt;a href="https://12factor.net/dev-prod-parity" rel="noopener noreferrer"&gt;Twelve-Factor App's tenth factor, dev/prod parity&lt;/a&gt;, names the trap: keep the gaps between dev and prod small — especially the &lt;strong&gt;tools&lt;/strong&gt; gap, where "tiny incompatibilities crop up, causing code that worked and passed tests in development to fail in production."&lt;/p&gt;

&lt;p&gt;Two different problems live under that one heading, and I'd conflated them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backing-service parity.&lt;/strong&gt; The canonical sin is SQLite locally, Postgres in prod. Sooner or later a behavioral difference bites at the worst moment. Same type, same version, both sides. No substitutions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manifest-level bugs.&lt;/strong&gt; A whole class of failure lives only at the Kubernetes object layer: misconfigured probes, thin RBAC, service discovery by in-cluster DNS, missing resource limits. Compose has none of these concepts, so it can surface none of these bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second bucket is the one that got me. A single Compose service is not one Kubernetes object — it's several, each carrying its own slice of production behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;Deployment&lt;/strong&gt; — how the pods run&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;Service&lt;/strong&gt; — how they're reached&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;ConfigMap&lt;/strong&gt; + &lt;strong&gt;Secret&lt;/strong&gt; — config and secrets&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;Ingress&lt;/strong&gt; — external access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a place to misconfigure something, and a place Compose never made me think about. There's a solid breakdown of what "production-like local" actually means at &lt;a href="https://dorokhovich.com/blog/local-k8s-production-like-environments?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-production-like-environments" rel="noopener noreferrer"&gt;this writeup on production-like local environments&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that didn't work: Kompose
&lt;/h2&gt;

&lt;p&gt;First instinct was to cheat and auto-convert. &lt;a href="https://github.com/kubernetes/kompose" rel="noopener noreferrer"&gt;Kompose&lt;/a&gt; is a real project under the Kubernetes org, so I ran it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a starting point, NOT a prod manifest&lt;/span&gt;
kompose convert &lt;span class="nt"&gt;-f&lt;/span&gt; compose.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kompose's own authors are honest: "our conversions are not always 1-1... but will get you 99% of the way there." Production lives in that last 1%. What the conversion silently drops or mangles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;depends_on&lt;/code&gt; is ignored.&lt;/strong&gt; Kubernetes has no "start B after A." You use init containers or, better, app-level retries — reconnect to Postgres until it answers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;build:&lt;/code&gt; doesn't build.&lt;/strong&gt; Kubernetes can't build from source. The image must already be pushed to a registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;network_mode: host&lt;/code&gt; and custom networks&lt;/strong&gt; map poorly or not at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind mounts vanish.&lt;/strong&gt; You're expected to use a ConfigMap/Secret.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The kicker: default Kompose output ships &lt;strong&gt;without &lt;code&gt;requests&lt;/code&gt;/&lt;code&gt;limits&lt;/code&gt;, without probes, with env vars in plaintext instead of Secrets.&lt;/strong&gt; The automation strips out exactly the production-like properties you were trying to gain. A draft, not a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that worked: a production-like k3d cluster
&lt;/h2&gt;

&lt;p&gt;I switched to running the service in a real local cluster with &lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;k3d&lt;/a&gt; (k3s in Docker). The discipline that mattered was deciding, on purpose, what to reproduce and what to simplify.&lt;/p&gt;

&lt;p&gt;Reproduce, no exceptions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# pin the SAME minor as prod. Docker tags can't use '+',&lt;/span&gt;
&lt;span class="c"&gt;# so it's 'v1.31.5-k3s1', not 'v1.30.2+k3s1'&lt;/span&gt;
k3d cluster create dev &lt;span class="nt"&gt;--image&lt;/span&gt; rancher/k3s:v1.31.5-k3s1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Same backing services by type and version — Postgres, never SQLite.&lt;/li&gt;
&lt;li&gt;Real &lt;code&gt;requests&lt;/code&gt;/&lt;code&gt;limits&lt;/code&gt;, so scheduler and eviction behavior is realistic.&lt;/li&gt;
&lt;li&gt;The exact probe pair that would've saved the first rollout — &lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;liveness and readiness&lt;/a&gt;: &lt;code&gt;/healthz&lt;/code&gt; liveness (process is up), &lt;code&gt;/ready&lt;/code&gt; readiness (checks the Postgres connection; no traffic while the DB is unreachable, but no restart either).&lt;/li&gt;
&lt;li&gt;ConfigMap + &lt;a href="https://kubernetes.io/docs/concepts/configuration/secret/" rel="noopener noreferrer"&gt;Secret&lt;/a&gt; instead of hardcoding; Ingress + in-cluster DNS so routing matches prod.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same real manifests, deployed the same declarative way as prod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; k8s/overlays/dev
kubectl rollout status deploy/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deliberately simplify, and write down why: prod scale and topology, managed cloud services (Postgres in a container instead of RDS), and load testing (doesn't belong on the box running the app it tests).&lt;/p&gt;

&lt;h3&gt;
  
  
  Proving the three bugs are dead, locally
&lt;/h3&gt;

&lt;p&gt;The point of parity is that the failures now reproduce on the laptop. I ran each one on purpose before trusting the rollout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. startup order: kill the DB, restart the app, watch it retry not crashloop&lt;/span&gt;
kubectl delete pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres
kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/api        &lt;span class="c"&gt;# "waiting for postgres... retry 3" then ready&lt;/span&gt;

&lt;span class="c"&gt;# 2. readiness gates traffic: app is up but DB down -&amp;gt; 0/1, no endpoint&lt;/span&gt;
kubectl get endpointslices &lt;span class="nt"&gt;-l&lt;/span&gt; kubernetes.io/service-name&lt;span class="o"&gt;=&lt;/span&gt;api

&lt;span class="c"&gt;# 3. secret is wired, not baked into the image&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/api &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;printenv &lt;/span&gt;DATABASE_URL   &lt;span class="c"&gt;# from Secret, not Dockerfile&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three passed at my desk. The next prod rollout was uneventful — the first one in that project that ever had been.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;docker-compose only&lt;/th&gt;
&lt;th&gt;Production-like k3d&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backing service&lt;/td&gt;
&lt;td&gt;often SQLite/stand-in&lt;/td&gt;
&lt;td&gt;same Postgres + version as prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;K8s version&lt;/td&gt;
&lt;td&gt;not modeled&lt;/td&gt;
&lt;td&gt;pinned to match prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;liveness + readiness, real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;plaintext env&lt;/td&gt;
&lt;td&gt;ConfigMap + Secret&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service discovery&lt;/td&gt;
&lt;td&gt;Compose networking&lt;/td&gt;
&lt;td&gt;real CoreDNS / Ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;depends_on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hides startup-order bugs&lt;/td&gt;
&lt;td&gt;forces real retry logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First prod rollout&lt;/td&gt;
&lt;td&gt;collapsed on unmodeled objects&lt;/td&gt;
&lt;td&gt;boring, matched local&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Works in Compose" validates the app, not the manifests.&lt;/strong&gt; Manifest bugs only appear after a real rollout — so test in k8s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production-like is not "everything like prod."&lt;/strong&gt; It's an environment where you know exactly what matches, what doesn't, and why. The list of documented simplifications is the most underrated line item on the whole checklist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't need the whole prod zoo.&lt;/strong&gt; Bring up only the namespace and services you're touching this session. Parity isn't recreating everything at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't try to copy prod exactly.&lt;/strong&gt; Copying prod onto a laptop is impossible and pointless. The goal is narrower: shrink the gap between how the service behaves at your desk and how it behaves in prod, so packaging and deployment bugs get caught before the rollout.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Keep Compose for the earliest, fastest hacking loop — it still earns its place there. Just never again mistake a green Compose run for validation of a Kubernetes deploy. The moment a service is bound for k8s, it gets tested in a local cluster on pinned versions and the same manifests as prod. My rollouts have since gotten boring, which after that first outage is the highest praise I have.&lt;/p&gt;

&lt;p&gt;Bottom line: a Compose file is structurally not equivalent to k8s manifests — pin the same versions and run the same manifests in a local cluster, or the manifest-layer bugs wait for prod to find them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://12factor.net/dev-prod-parity" rel="noopener noreferrer"&gt;The Twelve-Factor App — X. Dev/prod parity&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/kubernetes/kompose" rel="noopener noreferrer"&gt;Kompose — convert Compose to Kubernetes (kubernetes/kompose)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/k3d-io/k3d" rel="noopener noreferrer"&gt;k3d — run CNCF's k3s in Docker for local clusters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" rel="noopener noreferrer"&gt;Kubernetes docs — Configure Liveness, Readiness and Startup Probes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/configuration/secret/" rel="noopener noreferrer"&gt;Kubernetes docs — Secrets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-production-like-environments?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-production-like-environments" rel="noopener noreferrer"&gt;Walkthrough on production-like local environments&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
    <item>
      <title>The Kubernetes `:latest` Tag Problem: How a Mutable Tag Rolled Our Prod Back for 40 Minutes</title>
      <dc:creator>Metronom</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:15:50 +0000</pubDate>
      <link>https://dev.to/mtrnm/the-kubernetes-latest-tag-problem-how-a-mutable-tag-rolled-our-prod-back-for-40-minutes-1nlk</link>
      <guid>https://dev.to/mtrnm/the-kubernetes-latest-tag-problem-how-a-mutable-tag-rolled-our-prod-back-for-40-minutes-1nlk</guid>
      <description>&lt;h1&gt;
  
  
  The Kubernetes &lt;code&gt;:latest&lt;/code&gt; Tag Problem: How a Mutable Tag Rolled Our Prod Back for 40 Minutes
&lt;/h1&gt;

&lt;p&gt;Nobody deployed. Nobody ran &lt;code&gt;kubectl apply&lt;/code&gt;. At 02:00 the &lt;code&gt;orders&lt;/code&gt; API started throwing errors we'd fixed three weeks earlier, and it stayed broken for 40 minutes. The kubernetes latest tag problem is not theoretical. This is the incident that made me delete &lt;code&gt;:latest&lt;/code&gt; from every manifest we own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;On-call paged. Errors that were fixed weeks ago, live again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;2025-... POST /orders 500  InvalidStateError: order already settled
2025-... POST /orders 500
2025-... POST /orders 500
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy history: empty. No release. No &lt;code&gt;apply&lt;/code&gt;. No human near the cluster. Running code had changed anyway. First reflex was to blame a rogue actor with cluster creds. &lt;code&gt;kubectl get events&lt;/code&gt; and the audit log both said no — nobody had written to the API server in hours. The image had moved without a single Kubernetes write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;The Deployment referenced a mutable tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timeline, reconstructed from the kubelet events and registry push log:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Someone pushed a mid-refactor build and tagged it &lt;code&gt;latest&lt;/code&gt;. Never promoted it.&lt;/li&gt;
&lt;li&gt;Hours later a pod OOM'd and restarted.&lt;/li&gt;
&lt;li&gt;On restart the kubelet pulled &lt;code&gt;latest&lt;/code&gt; again — now pointing at the broken build.&lt;/li&gt;
&lt;li&gt;Pod came up on code nobody chose to deploy. Prod changed its own version while we slept.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three separate properties of &lt;code&gt;latest&lt;/code&gt; conspired here, and the &lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;Kubernetes image docs&lt;/a&gt; warn about all of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;latest&lt;/code&gt; is mutable.&lt;/strong&gt; It points at a different digest tomorrow. You cannot answer "what is running in prod?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-running &lt;code&gt;apply&lt;/code&gt; is a no-op.&lt;/strong&gt; Kubernetes rolls out only when the pod template changes. The literal string &lt;code&gt;image: orders:latest&lt;/code&gt; never changes, so the cluster sees nothing to do even when the registry digest is completely different.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A restart re-pulls.&lt;/strong&gt; &lt;code&gt;imagePullPolicy&lt;/code&gt; defaults to &lt;code&gt;Always&lt;/code&gt; for &lt;code&gt;latest&lt;/code&gt; (and &lt;code&gt;IfNotPresent&lt;/code&gt; for a pinned tag). So &lt;code&gt;latest&lt;/code&gt; maximizes the odds of a surprise pull on every restart. The inverse trap is just as ugly: a pinned tag with &lt;code&gt;IfNotPresent&lt;/code&gt; can stick on a stale local layer and never pull the fix you just pushed. Both are the same disease — the running image and the name you typed have drifted apart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a solid breakdown of the full local-to-CI path that this class of bug forced on us at &lt;a href="https://dorokhovich.com/blog/local-k8s-preparing-for-deployment-and-ci?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-preparing-for-deployment-and-ci" rel="noopener noreferrer"&gt;this writeup on preparing a local setup for deployment and CI&lt;/a&gt;. I'll stick to the fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Immutable tags, generated in CI
&lt;/h3&gt;

&lt;p&gt;Rule: every image is tagged by something that resolves to exactly one build. Short SHA, semver, or a digest pin for maximum determinism.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/orders@sha256:45b23dee...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't hand-write tags. &lt;a href="https://github.com/docker/metadata-action" rel="noopener noreferrer"&gt;&lt;code&gt;docker/metadata-action&lt;/code&gt;&lt;/a&gt; emits them from the git context and writes OCI labels like &lt;code&gt;org.opencontainers.image.revision&lt;/code&gt;. "What's in prod?" collapses from an investigation to one label read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker inspect ghcr.io/acme/orders:sha-abc1234 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{ index .Config.Labels "org.opencontainers.image.revision" }}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. One base, kill the copy-pasted &lt;code&gt;k8s-prod/&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The other sin: a &lt;code&gt;k8s/&lt;/code&gt; folder copied to &lt;code&gt;k8s-prod/&lt;/code&gt; months earlier. Two copies drift. Ours had, and prod's memory limit no longer matched what we'd tested. Configuration drift, cured by a single base with only the diffs layered on top.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/" rel="noopener noreferrer"&gt;Kustomize&lt;/a&gt; — built into &lt;code&gt;kubectl&lt;/code&gt;, plain YAML, no templating engine to reason about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# overlays/prod/kustomization.yaml&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;../../base&lt;/span&gt;
&lt;span class="na"&gt;namePrefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod-&lt;/span&gt;
&lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;orders&lt;/span&gt;
    &lt;span class="na"&gt;newName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/orders&lt;/span&gt;
    &lt;span class="na"&gt;newTag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sha-abc1234&lt;/span&gt;    &lt;span class="c1"&gt;# swap the image without touching the Deployment&lt;/span&gt;
&lt;span class="na"&gt;patches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;replicas-patch.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# render and eyeball it before it touches the cluster&lt;/span&gt;
kustomize build overlays/prod | kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;images&lt;/code&gt; field is the quiet win: CI rewrites the tag without editing the Deployment YAML. Helm with a per-env &lt;code&gt;values-prod.yaml&lt;/code&gt; lands the same place — just mind &lt;code&gt;-f&lt;/code&gt; ordering, last file wins, and swapping the order silently changes your prod replica count.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A pipeline that physically cannot ship &lt;code&gt;:latest&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Push-based and deliberately dumb: commit → build → push → update manifest to the immutable tag → apply. GitHub Actions skeleton, using Docker's &lt;a href="https://github.com/docker/build-push-action" rel="noopener noreferrer"&gt;&lt;code&gt;build-push-action&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-deploy&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/metadata-action@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/acme/orders&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;type=sha&lt;/span&gt;
            &lt;span class="s"&gt;type=semver,pattern={{version}}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.tags }}&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.meta.outputs.labels }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "${{ secrets.KUBECONFIG }}" &amp;gt; kubeconfig&lt;/span&gt;
          &lt;span class="s"&gt;export KUBECONFIG=kubeconfig&lt;/span&gt;
          &lt;span class="s"&gt;kubectl set image deployment/orders \&lt;/span&gt;
            &lt;span class="s"&gt;orders=ghcr.io/acme/orders:sha-${GITHUB_SHA::7}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The deploy step sets an immutable tag, never &lt;code&gt;latest&lt;/code&gt;. Now the pod template genuinely differs, the rollout is honest, and a restart can't re-pull a mystery image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing the outage on k3d first
&lt;/h3&gt;

&lt;p&gt;I didn't ship any of this blind. I reproduced the exact failure on a laptop k3d cluster, then confirmed the fix killed it. The whole loop is a few minutes and costs nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k3d cluster create latest-repro
&lt;span class="c"&gt;# push two builds to the same mutable tag&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; k3d-registry:5000/orders:latest ./good &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker push k3d-registry:5000/orders:latest
kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; overlays/local           &lt;span class="c"&gt;# Deployment references :latest&lt;/span&gt;
kubectl rollout status deploy/orders

&lt;span class="c"&gt;# now poison latest and force a restart, no apply involved&lt;/span&gt;
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; k3d-registry:5000/orders:latest ./broken &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker push k3d-registry:5000/orders:latest
kubectl delete pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orders          &lt;span class="c"&gt;# simulate the 02:00 crash&lt;/span&gt;
kubectl get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;orders &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].spec.containers[0].image}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pod came back on the broken image with an empty deploy history — the outage, on demand. Re-run the same script against the SHA-pinned overlay and the delete/restart brings the &lt;em&gt;same&lt;/em&gt; image back every time. That's the whole proof. Once it held, I trusted it in prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before / after
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Image tags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;immutable &lt;code&gt;sha-…&lt;/code&gt; / digest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What's in prod?"&lt;/td&gt;
&lt;td&gt;an investigation&lt;/td&gt;
&lt;td&gt;one label read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Environments&lt;/td&gt;
&lt;td&gt;copy-pasted &lt;code&gt;k8s-prod/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;one base + overlays&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drift&lt;/td&gt;
&lt;td&gt;silent, unbounded&lt;/td&gt;
&lt;td&gt;the diff is the only diff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restart&lt;/td&gt;
&lt;td&gt;may pull broken &lt;code&gt;latest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;pinned, deterministic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollout on redeploy&lt;/td&gt;
&lt;td&gt;sometimes a no-op&lt;/td&gt;
&lt;td&gt;always honest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Guardrail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Grep CI and manifests for &lt;code&gt;:latest&lt;/code&gt; in a pre-merge check. No merge if it hits.&lt;/li&gt;
&lt;li&gt;Most local Kubernetes work carried straight to prod: same Dockerfile, same Kustomize base, same probes. Prod parity is the payoff. Prod only &lt;em&gt;adds&lt;/em&gt; — registry, Secrets, TLS, HPA, PodDisruptionBudget.&lt;/li&gt;
&lt;li&gt;Some things must never cross the line: &lt;code&gt;--reload&lt;/code&gt;, file-sync dev accelerators, exposed debug ports. The prod overlay is the mechanism that keeps them out.&lt;/li&gt;
&lt;li&gt;Pin a digest, not just a tag, for anything you truly can't afford to move — &lt;code&gt;@sha256:...&lt;/code&gt; is the only reference in Kubernetes that a registry cannot repoint under you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Move off the push pipeline sooner. It has three built-in weaknesses I already know will bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI holds the kubeconfig.&lt;/strong&gt; CI access equals prod access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift is invisible.&lt;/strong&gt; Someone runs &lt;code&gt;kubectl edit&lt;/code&gt; by hand and CI never notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No automatic rollback.&lt;/strong&gt; Ship broken, revert by hand, at 02:00.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those three are exactly what GitOps removes. Per the &lt;a href="https://opengitops.dev/" rel="noopener noreferrer"&gt;CNCF OpenGitOps principles&lt;/a&gt;, Git is the source of truth and an in-cluster agent (Argo CD or Flux) pulls and reconciles drift. CI's job ends at pushing the image and committing the tag. That's the next rung: CI builds an immutable image → a bot bumps the tag in Git → the agent applies it.&lt;/p&gt;

&lt;p&gt;Bottom line: the outage wasn't a Kubernetes flaw, it was a mutable tag plus a copy-pasted folder — pin an immutable SHA and collapse onto one base before your own 02:00 page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/concepts/containers/images/" rel="noopener noreferrer"&gt;Kubernetes docs — Images (avoid &lt;code&gt;:latest&lt;/code&gt;; imagePullPolicy defaults)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/" rel="noopener noreferrer"&gt;Kubernetes docs — Declarative management with Kustomize (bases &amp;amp; overlays)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/docker/metadata-action" rel="noopener noreferrer"&gt;docker/metadata-action — immutable image tags &amp;amp; OCI labels&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/docker/build-push-action" rel="noopener noreferrer"&gt;docker/build-push-action — build and push images in CI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://opengitops.dev/" rel="noopener noreferrer"&gt;CNCF OpenGitOps — the four GitOps principles&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dorokhovich.com/blog/local-k8s-preparing-for-deployment-and-ci?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=success-story&amp;amp;utm_content=local-k8s-preparing-for-deployment-and-ci" rel="noopener noreferrer"&gt;Local-to-production deployment &amp;amp; CI walkthrough&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
    </item>
  </channel>
</rss>
