<?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: DevOps Start</title>
    <description>The latest articles on DEV Community by DevOps Start (@devopsstart).</description>
    <link>https://dev.to/devopsstart</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%2F3862044%2F9672d1b5-f8fd-4473-998f-30a47c07608f.png</url>
      <title>DEV Community: DevOps Start</title>
      <link>https://dev.to/devopsstart</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devopsstart"/>
    <language>en</language>
    <item>
      <title>Docker TUI Dashboard: Manage Containers with Lazydocker</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Tue, 11 Aug 2026 08:15:42 +0000</pubDate>
      <link>https://dev.to/devopsstart/docker-tui-dashboard-manage-containers-with-lazydocker-890</link>
      <guid>https://dev.to/devopsstart/docker-tui-dashboard-manage-containers-with-lazydocker-890</guid>
      <description>&lt;p&gt;If you keep three terminal tabs open just to run &lt;code&gt;docker ps&lt;/code&gt;, &lt;code&gt;docker logs -f&lt;/code&gt;,&lt;br&gt;
and &lt;code&gt;docker stats&lt;/code&gt;, replace all of them with one screen. &lt;a href="https://github.com/jesseduffield/lazydocker" rel="noopener noreferrer"&gt;Lazydocker&lt;/a&gt;&lt;br&gt;
is a terminal UI that puts your containers, images, volumes, and networks in a&lt;br&gt;
single dashboard, with live logs and resource graphs next to the list you are&lt;br&gt;
scrolling. It is a single Go binary, it talks to the same Docker daemon your CLI&lt;br&gt;
already uses, and it needs no config to be useful on the first run.&lt;/p&gt;
&lt;h2&gt;
  
  
  Install it
&lt;/h2&gt;

&lt;p&gt;Pick whichever fits your setup. All three drop a &lt;code&gt;lazydocker&lt;/code&gt; binary on your&lt;br&gt;
&lt;code&gt;PATH&lt;/code&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="nv"&gt;$ &lt;/span&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;jesseduffield/lazydocker/lazydocker   &lt;span class="c"&gt;# macOS or Linuxbrew&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/jesseduffield/lazydocker@latest   &lt;span class="c"&gt;# any Go 1.21+ toolchain&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start it from any directory:&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="nv"&gt;$ &lt;/span&gt;lazydocker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are inside a Compose project, run it there. Lazydocker reads the&lt;br&gt;
&lt;code&gt;docker-compose.yml&lt;/code&gt; in your working directory and adds a project view so you&lt;br&gt;
can restart the whole stack, not just one container.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layout
&lt;/h2&gt;

&lt;p&gt;The left column is a stack of panels: Project, Containers, Images, Volumes, and&lt;br&gt;
Networks. The right side is the main view, and it reacts to whatever you have&lt;br&gt;
selected on the left. Highlight a container and the main view shows its logs.&lt;br&gt;
Switch the main view's tabs and you get its config, its stats as an ASCII CPU and&lt;br&gt;
memory graph, and its environment. Nothing to configure, no query to type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys worth memorizing
&lt;/h2&gt;

&lt;p&gt;You drive the whole thing from a handful of keys. These are the navigation keys&lt;br&gt;
that never change:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;1&lt;/code&gt;-&lt;code&gt;5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Jump straight to Project, Containers, Images, Volumes, or Networks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;[&lt;/code&gt; / &lt;code&gt;]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Cycle the tabs in the main view (logs, stats, config, env)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open the action menu for whatever is highlighted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;+&lt;/code&gt; / &lt;code&gt;_&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Grow or shrink the focused panel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Esc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Go back one level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;q&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one to lean on is &lt;code&gt;x&lt;/code&gt;. Highlight a container, press &lt;code&gt;x&lt;/code&gt;, and you get a menu&lt;br&gt;
of everything you can do to it: restart, stop, remove, prune, attach, or exec a&lt;br&gt;
shell. Because the menu is generated from your installed version, it is always&lt;br&gt;
the authoritative list of shortcuts, so you never have to guess whether &lt;code&gt;r&lt;/code&gt;&lt;br&gt;
restarts or &lt;code&gt;s&lt;/code&gt; stops on the build you have. The common defaults are &lt;code&gt;r&lt;/code&gt; to&lt;br&gt;
restart, &lt;code&gt;s&lt;/code&gt; to stop, and &lt;code&gt;d&lt;/code&gt; to remove, but let the &lt;code&gt;x&lt;/code&gt; menu confirm them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things it replaces on day one
&lt;/h2&gt;

&lt;p&gt;Tailing logs across a multi-service app. Select each container and its live log&lt;br&gt;
stream is already in the main view, no &lt;code&gt;docker logs -f &amp;lt;id&amp;gt;&lt;/code&gt; and no copying&lt;br&gt;
container IDs.&lt;/p&gt;

&lt;p&gt;Cleaning up disk. Press &lt;code&gt;x&lt;/code&gt; on the Images panel to prune dangling images, or on a&lt;br&gt;
stopped container to remove it, instead of hunting for the right&lt;br&gt;
&lt;code&gt;docker image prune&lt;/code&gt; incantation.&lt;/p&gt;

&lt;p&gt;Spotting the container that is eating your CPU. The stats tab draws a live graph&lt;br&gt;
per container, so you see the offender without leaving the dashboard or parsing&lt;br&gt;
&lt;code&gt;docker stats&lt;/code&gt; columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration, when you want it
&lt;/h2&gt;

&lt;p&gt;Lazydocker writes a config file the first time it runs. On Linux it lands at&lt;br&gt;
&lt;code&gt;~/.config/jesseduffield/lazydocker/config.yml&lt;/code&gt;, and on macOS under&lt;br&gt;
&lt;code&gt;~/Library/Application Support&lt;/code&gt;. You can rebind keys, change the log tail length,&lt;br&gt;
and add custom commands there, for example a one-key &lt;code&gt;docker compose up -d&lt;/code&gt; for&lt;br&gt;
the current project. The defaults are sensible, so treat the config as optional&lt;br&gt;
polish rather than required setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for something else
&lt;/h2&gt;

&lt;p&gt;Lazydocker is aimed at local development, where you are juggling a handful of&lt;br&gt;
containers and want fast feedback. If you only need a live top-style table of&lt;br&gt;
container metrics, &lt;code&gt;ctop&lt;/code&gt; is lighter. If you are managing production hosts and&lt;br&gt;
want a web UI with role-based access, that is Portainer's job, not a terminal&lt;br&gt;
dashboard's. For local Docker work, though, lazydocker earns a permanent alias.&lt;/p&gt;

&lt;p&gt;For the CLI commands lazydocker wraps, the &lt;a href="https://docs.docker.com/reference/cli/docker/" rel="noopener noreferrer"&gt;Docker CLI reference&lt;/a&gt;&lt;br&gt;
is the source of truth. If your daemon connection is broken before you even get&lt;br&gt;
this far, start with &lt;a href="https://dev.to/troubleshooting/fix-gitlab-ci-docker-daemon-connection-error-in-3-steps"&gt;this GitLab CI Docker daemon fix&lt;/a&gt;,&lt;br&gt;
and when you are ready to slim the images you are inspecting, see&lt;br&gt;
&lt;a href="https://dev.to/blog/docker-multi-stage-builds-smaller-secure-production-images"&gt;Docker multi-stage builds&lt;/a&gt;.&lt;br&gt;
If you live on the Kubernetes side too, the same keyboard-first habit pays off in&lt;br&gt;
the &lt;a href="https://dev.to/tips/kubectl-cheat-sheet"&gt;kubectl cheat sheet&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Jaeger 2.18 ClickHouse Backend Setup: 8.6x Compression</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sat, 08 Aug 2026 08:01:23 +0000</pubDate>
      <link>https://dev.to/devopsstart/jaeger-218-clickhouse-backend-setup-86x-compression-1m68</link>
      <guid>https://dev.to/devopsstart/jaeger-218-clickhouse-backend-setup-86x-compression-1m68</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Jaeger 2.18 ships a native ClickHouse storage backend, and you turn it on with a single storage stanza in your Jaeger v2 config file. No sidecar plugin, no gRPC storage process. You point Jaeger at a ClickHouse instance, set &lt;code&gt;create_schema: true&lt;/code&gt;, and it builds the tables on startup. On the project's own 10 million span benchmark it hit 8.6x compression on the spans table while sustaining more than 50k spans per second of ingestion.&lt;/p&gt;

&lt;p&gt;One caveat before you wire it into anything important: the ClickHouse backend is alpha in 2.18. Pin the exact version, keep it out of your primary production path for now, and treat it as a serious evaluation rather than a drop-in replacement for Elasticsearch or Cassandra.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A reachable ClickHouse server (a local &lt;code&gt;clickhouse-server&lt;/code&gt; container is fine for testing).&lt;/li&gt;
&lt;li&gt;The Jaeger v2 binary or the &lt;code&gt;jaegertracing/jaeger:2.18.0&lt;/code&gt; image. Jaeger v2 is built on the OpenTelemetry Collector, so its config uses the collector's extension and pipeline model, not the old v1 flags.&lt;/li&gt;
&lt;li&gt;An app already exporting OTLP traces, or anything that can send OTLP to port 4317/4318.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The config
&lt;/h2&gt;

&lt;p&gt;Jaeger v2 declares storage as an extension and wires it into the traces pipeline through an exporter. Save this as &lt;code&gt;config-clickhouse.yaml&lt;/code&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;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jaeger_storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;backends&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;clickhouse-storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;clickhouse&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;localhost:9000&lt;/span&gt;
          &lt;span class="na"&gt;database&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jaeger&lt;/span&gt;
          &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;basic&lt;/span&gt;&lt;span class="pi"&gt;:&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;default&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;password&lt;/span&gt;
          &lt;span class="na"&gt;create_schema&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;jaeger_query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;clickhouse-storage&lt;/span&gt;

&lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;protocols&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;grpc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:4317&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0:4318&lt;/span&gt;

&lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jaeger_storage_exporter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;trace_storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;clickhouse-storage&lt;/span&gt;

&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;jaeger_storage&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;jaeger_query&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pipelines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;traces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;receivers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;otlp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;processors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;batch&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;exporters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;jaeger_storage_exporter&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fields do the real work. &lt;code&gt;addresses&lt;/code&gt; is the ClickHouse native protocol endpoint (port 9000, not the 8123 HTTP port). &lt;code&gt;database&lt;/code&gt; is the target database, which should already exist. &lt;code&gt;create_schema: true&lt;/code&gt; tells Jaeger to create the span and index tables inside that database on first start, so you do not hand-write DDL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;

&lt;p&gt;Start ClickHouse, then start Jaeger against the config. Both run as &lt;a href="https://docs.docker.com/reference/cli/docker/container/run/" rel="noopener noreferrer"&gt;Docker containers&lt;/a&gt;, so a laptop is enough to kick the tires:&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="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; clickhouse &lt;span class="nt"&gt;-p&lt;/span&gt; 9000:9000 clickhouse/clickhouse-server:latest
&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;clickhouse clickhouse-client &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"CREATE DATABASE IF NOT EXISTS jaeger"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; jaeger &lt;span class="nt"&gt;--network&lt;/span&gt; host &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/config-clickhouse.yaml:/etc/jaeger/config.yaml"&lt;/span&gt; jaegertracing/jaeger:2.18.0 &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/jaeger/config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI comes up on &lt;code&gt;http://localhost:16686&lt;/code&gt;. Send a few OTLP spans, then confirm they landed in ClickHouse rather than trusting the UI alone:&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="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;clickhouse clickhouse-client &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"SELECT count() FROM jaeger.spans"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that count climbs as traffic flows, the pipeline is healthy end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the compression actually buys you
&lt;/h2&gt;

&lt;p&gt;The headline number comes from the Jaeger team's published benchmark on a 10 million span dataset. ClickHouse stores spans in columnar form and compresses each column independently, which is why trace data (highly repetitive service names, operation names, and attribute keys) shrinks so well.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Reported result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spans table compression&lt;/td&gt;
&lt;td&gt;8.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sustained ingestion&lt;/td&gt;
&lt;td&gt;50k+ spans/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace-by-ID retrieval&lt;/td&gt;
&lt;td&gt;~100 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical search query&lt;/td&gt;
&lt;td&gt;under 50 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are the project's figures on their hardware, not a promise for your cluster. Your ratio moves with attribute cardinality and how many unique tag values you carry per span. Still, an 8.6x reduction on the largest table is the kind of change that turns a storage line item you dread into one you stop thinking about, which is the same pressure driving teams toward &lt;a href="https://dev.to/blog/kubernetes-finops-real-time-cost-observability-optimization"&gt;real-time cost observability&lt;/a&gt; elsewhere in the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things worth knowing
&lt;/h2&gt;

&lt;p&gt;Service Performance Monitoring works without a second datastore. In v2.18 Jaeger can compute latency, call rate, and error rate directly from the spans stored in ClickHouse, so you do not need a separate Prometheus-backed metrics path just to light up the Monitor tab.&lt;/p&gt;

&lt;p&gt;Set retention with ClickHouse TTL, not a Jaeger flag. Because the tables are plain ClickHouse tables, you control retention with a TTL clause on the spans table (for example &lt;code&gt;TTL timestamp + INTERVAL 30 DAY&lt;/code&gt;). That keeps trace retention a database concern instead of an application one.&lt;/p&gt;

&lt;p&gt;If you are running the collector tier that feeds this backend, the same OpenTelemetry foundations apply on the ingest side. See &lt;a href="https://dev.to/blog/manage-otel-collectors-at-scale-with-opamp"&gt;managing OTel collectors at scale&lt;/a&gt; for the fleet story, and &lt;a href="https://dev.to/tutorials/how-to-set-up-llm-observability-with-opentelemetry"&gt;setting up observability with OpenTelemetry&lt;/a&gt; if you are still standing up the tracing pipeline itself.&lt;/p&gt;

&lt;p&gt;Start it on a staging Jaeger, point one service's traces at it, and watch the compression ratio on your own data before you commit. Alpha or not, the storage math is hard to argue with.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>monitoring</category>
      <category>opentelemetry</category>
    </item>
    <item>
      <title>Manage Kubeflow AI Workloads with Headlamp Plugin</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Thu, 06 Aug 2026 10:00:42 +0000</pubDate>
      <link>https://dev.to/devopsstart/manage-kubeflow-ai-workloads-with-headlamp-plugin-58kb</link>
      <guid>https://dev.to/devopsstart/manage-kubeflow-ai-workloads-with-headlamp-plugin-58kb</guid>
      <description>&lt;p&gt;When a Kubeflow notebook hangs on startup or a Katib trial dies without a clear reason, you usually end up in a terminal running &lt;code&gt;kubectl describe&lt;/code&gt;, &lt;code&gt;kubectl get events&lt;/code&gt;, and &lt;code&gt;kubectl logs&lt;/code&gt; across three namespaces to reconstruct what happened. The &lt;a href="https://headlamp.dev/" rel="noopener noreferrer"&gt;Headlamp&lt;/a&gt; Kubeflow plugin removes most of that. It teaches the Headlamp dashboard to read Kubeflow's custom resources directly, so a stuck Notebook, a failed Pipeline run, or a bad AutoML suggestion shows up as a first-class object with its status conditions, owned pods, and configuration in one view.&lt;/p&gt;

&lt;p&gt;This post walks through what the plugin actually surfaces, how to install it on a desktop or in-cluster Headlamp, and how it changes the way you debug ML workloads on Kubernetes. It was introduced on the &lt;a href="https://kubernetes.io/blog/2026/07/13/introducing-headlamp-plugin-for-kubeflow/" rel="noopener noreferrer"&gt;Kubernetes blog&lt;/a&gt; in July 2026 and is maintained under Kubernetes SIG UI with an Apache 2.0 license.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: Kubeflow hides behind CRDs
&lt;/h2&gt;

&lt;p&gt;Kubeflow is not one application. It is a set of controllers that each register their own Custom Resource Definitions and reconcile them into ordinary Kubernetes objects. A &lt;code&gt;Notebook&lt;/code&gt; becomes a StatefulSet and a Pod. A Katib &lt;code&gt;Experiment&lt;/code&gt; fans out into &lt;code&gt;Trial&lt;/code&gt; and &lt;code&gt;Suggestion&lt;/code&gt; resources, each of which spawns Jobs. A &lt;code&gt;Pipeline&lt;/code&gt; run creates a graph of Argo Workflow steps. The Kubeflow Central Dashboard gives data scientists a clean surface over all of this, but it is built for the person running experiments, not the person keeping the cluster healthy.&lt;/p&gt;

&lt;p&gt;When something breaks, the operator's questions are Kubernetes questions. Which pod is pending, and why? What does the resource's &lt;code&gt;status&lt;/code&gt; block say? Is the controller even reconciling this object? The Central Dashboard was not designed to answer those, so operators fall back to &lt;code&gt;kubectl&lt;/code&gt;. That is fine for one broken notebook. It stops scaling the moment you are supporting a shared cluster with dozens of researchers and several Kubeflow components installed.&lt;/p&gt;

&lt;p&gt;Headlamp already solves the general version of this problem. It is a lightweight, extensible Kubernetes UI that runs as a desktop app or in-cluster, and its plugin system lets anyone add first-class views for custom resources. The Kubeflow plugin is that idea applied to Kubeflow's CRDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the plugin surfaces
&lt;/h2&gt;

&lt;p&gt;The plugin adds operator-focused views for the resources that matter when you are troubleshooting AI/ML workloads. As of the current release it recognizes these component groups:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Custom resources&lt;/th&gt;
&lt;th&gt;API group&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Notebooks&lt;/td&gt;
&lt;td&gt;Notebook, Profile, PodDefault&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubeflow.org/v1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipelines&lt;/td&gt;
&lt;td&gt;Pipeline, PipelineVersion&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pipelines.kubeflow.org/v2beta1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Katib / AutoML&lt;/td&gt;
&lt;td&gt;Experiment, Trial, Suggestion&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubeflow.org/v1beta1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training&lt;/td&gt;
&lt;td&gt;TrainJob, TrainingRuntime, ClusterTrainingRuntime&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubeflow.org/v1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spark&lt;/td&gt;
&lt;td&gt;SparkApplication, ScheduledSparkApplication&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sparkoperator.k8s.io&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For each object you get the same drill-down Headlamp gives any native resource: the live status conditions, the owned pods with their phases, the events, and the full YAML. That is the difference that matters during an incident. Instead of guessing that a stuck notebook is a scheduling problem, you open the &lt;code&gt;Notebook&lt;/code&gt;, see its Pod stuck &lt;code&gt;Pending&lt;/code&gt;, click through to the Pod, and read the &lt;code&gt;FailedScheduling&lt;/code&gt; event that says there is no node with a free GPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-detection: you install only what you run
&lt;/h2&gt;

&lt;p&gt;Very few teams run all of Kubeflow. You might deploy only Katib for hyperparameter tuning, or only the Notebooks controller for a shared research environment. The plugin handles this by checking the cluster's API for which Kubeflow CRDs are actually registered, then showing sidebar sections only for the components it finds.&lt;/p&gt;

&lt;p&gt;The practical effect is that the plugin is safe to install everywhere. If a cluster has no Katib CRDs, the Katib section simply does not appear. There is no configuration file listing which features to turn on, and no broken menu entries pointing at resources that do not exist. Install a new component later with Helm or Kustomize, refresh Headlamp, and its section shows up on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing on a desktop Headlamp
&lt;/h2&gt;

&lt;p&gt;The fastest way to try the plugin is the desktop app. Download and run &lt;a href="https://headlamp.dev/" rel="noopener noreferrer"&gt;Headlamp&lt;/a&gt;, point it at a cluster your &lt;code&gt;kubeconfig&lt;/code&gt; can reach, and open the Plugin Catalog from the sidebar. Search for the Kubeflow plugin, install it, and reload. If the target cluster has any Kubeflow CRDs, the new sections appear in the left navigation.&lt;/p&gt;

&lt;p&gt;This path is ideal for an operator who wants to inspect a cluster from a laptop without deploying anything into it. The plugin runs inside your local Headlamp process and talks to the cluster through the same API access your &lt;code&gt;kubeconfig&lt;/code&gt; already grants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing in-cluster
&lt;/h2&gt;

&lt;p&gt;For a shared dashboard the whole team uses, run Headlamp in the cluster and load the plugin through the Helm chart's plugin manager. The chart supports declaring plugins in &lt;code&gt;values.yaml&lt;/code&gt;, and a sidecar keeps them in sync:&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;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pluginsDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/headlamp/plugins&lt;/span&gt;
&lt;span class="na"&gt;pluginsManager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&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;watchPlugins&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two supported in-cluster patterns. The plugin manager shown above pulls and updates plugins for you, which is the recommended approach. The alternative is an &lt;code&gt;initContainer&lt;/code&gt; that copies plugin files into a shared volume before Headlamp starts. Both end with the plugin's static assets sitting in the directory Headlamp reads at boot. Pick the plugin manager unless you have a reason to bake plugins into an image yourself.&lt;/p&gt;

&lt;p&gt;Whichever you choose, remember that Headlamp respects the RBAC of whoever is logged in. The Kubeflow views do not grant new access. A user who cannot &lt;code&gt;get notebooks&lt;/code&gt; in a namespace will not see them in the plugin either, which is exactly what you want on a multi-tenant research cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on a throwaway cluster
&lt;/h2&gt;

&lt;p&gt;You do not need a real Kubeflow install to see how the plugin behaves. Because it keys off CRDs, applying the definitions alone is enough to light up the UI. Spin up a local cluster with kind:&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="nv"&gt;$ &lt;/span&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; headlamp-kubeflow
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl config use-context kind-headlamp-kubeflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply a component's CRDs. For the Notebooks controller the upstream manifests install the &lt;code&gt;Notebook&lt;/code&gt; definition among others:&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="nv"&gt;$ &lt;/span&gt;kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="s2"&gt;"github.com/kubeflow/notebooks/notebook-controller/config/crd?ref=main"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the CRD registered:&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="nv"&gt;$ &lt;/span&gt;kubectl get crd notebooks.kubeflow.org
NAME                     CREATED AT
notebooks.kubeflow.org   2026-08-05T09:14:22Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open Headlamp against this cluster and the Notebooks section appears. Create a sample &lt;code&gt;Notebook&lt;/code&gt; object and you can watch the plugin render its status even before a real controller reconciles it, which is a fast way to learn the views without provisioning GPUs. When you are done, delete the cluster with &lt;code&gt;$ kind delete cluster --name headlamp-kubeflow&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A realistic debugging pass
&lt;/h2&gt;

&lt;p&gt;Here is how a shared-cluster incident looks with the plugin in place. A researcher reports that their training run "just stopped." The steps you would otherwise do by hand collapse into a short click path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the Katib section and find the researcher's &lt;code&gt;Experiment&lt;/code&gt;. Its status shows &lt;code&gt;Failed&lt;/code&gt; with a condition message pointing at the last &lt;code&gt;Trial&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Click into that &lt;code&gt;Trial&lt;/code&gt;. The plugin shows the Job it created and the Pod that ran it, along with the Pod's phase.&lt;/li&gt;
&lt;li&gt;Open the Pod. Its last state is &lt;code&gt;Terminated&lt;/code&gt; with reason &lt;code&gt;OOMKilled&lt;/code&gt; and exit code 137.&lt;/li&gt;
&lt;li&gt;Read the container spec in the same view: the memory limit is 4Gi, well under what the model needs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is a bigger memory request in the trial template, but the point is the diagnosis. You went from a vague "it stopped" to &lt;code&gt;OOMKilled&lt;/code&gt; without typing a single &lt;code&gt;kubectl&lt;/code&gt; command or context-switching between namespaces. Memory pressure and eviction are the same failure modes you already know from general Kubernetes work, and if you want a deeper reference on reading pod-level failure states, our guide on &lt;a href="https://dev.to/blog/fix-kubernetes-crashloopbackoff-root-causes-diagnosis"&gt;fixing CrashLoopBackOff&lt;/a&gt; covers the status conditions the plugin puts in front of you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits alongside the Central Dashboard
&lt;/h2&gt;

&lt;p&gt;The Kubeflow Central Dashboard and the Headlamp plugin are not competitors. They serve different people. The Central Dashboard is where a data scientist launches notebooks, submits pipelines, and reviews experiment results. The Headlamp plugin is where an operator or SRE answers infrastructure questions about those same objects: scheduling, resource limits, controller health, and pod lifecycle.&lt;/p&gt;

&lt;p&gt;On a small team one person wears both hats and might use both tools. On a larger platform team, the split is cleaner. Researchers live in the Central Dashboard. The people running the cluster live in Headlamp, and the Kubeflow plugin means they no longer have to translate every ML abstraction back into raw pods by hand. If your team is standing up this kind of shared ML platform, it pairs naturally with broader workload visibility work like &lt;a href="https://dev.to/tutorials/llm-observability-on-kubernetes-a-practical-guide"&gt;LLM observability on Kubernetes&lt;/a&gt; and the capacity planning behind &lt;a href="https://dev.to/blog/kubernetes-hpa-deep-dive-autoscaling-explained"&gt;horizontal pod autoscaling&lt;/a&gt; for bursty training jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not do
&lt;/h2&gt;

&lt;p&gt;Be clear about the plugin's scope so you do not expect the wrong thing from it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It is read and inspect focused. It surfaces state and configuration for troubleshooting; it is not a control plane for editing experiment definitions or launching training runs. Do that through Kubeflow's own tooling.&lt;/li&gt;
&lt;li&gt;It reflects only what the CRDs and controllers expose. If a controller writes a thin &lt;code&gt;status&lt;/code&gt; block, the plugin can only show that thin block. It reads the cluster, it does not add telemetry the cluster is not already recording.&lt;/li&gt;
&lt;li&gt;It does not replace metrics and logs pipelines. For GPU utilization trends, cost, or historical training throughput you still want Prometheus, your logging stack, and a proper observability setup. The plugin answers "what is the state of this object right now," not "how has this behaved over the last week."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is a knock on the tool. It is a focused operator UI, and knowing the boundary keeps you from reaching for it when you actually need a time-series dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you operate a cluster that runs Kubeflow, the Headlamp Kubeflow plugin is a low-cost addition that pays off the first time a notebook or trial breaks. It is open source under Kubernetes SIG UI, it detects which components you run so it is safe to install broadly, and it turns the CRD archaeology of an ML incident into a few clicks through resources you already understand. Install it on a desktop Headlamp to try it in minutes, then move it into an in-cluster deployment through the plugin manager once your team wants a shared view. The next time someone says their training run "just stopped," you will find out why without opening a terminal.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aiops</category>
      <category>observability</category>
      <category>kubectl</category>
    </item>
    <item>
      <title>Azure SDK for Rust Migration Guide: REST to GA Crates</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:23:29 +0000</pubDate>
      <link>https://dev.to/devopsstart/azure-sdk-for-rust-migration-guide-rest-to-ga-crates-1hnn</link>
      <guid>https://dev.to/devopsstart/azure-sdk-for-rust-migration-guide-rest-to-ga-crates-1hnn</guid>
      <description>&lt;p&gt;If you have been hitting Azure REST endpoints from Rust with hand-rolled &lt;code&gt;reqwest&lt;/code&gt; calls and a pile of header-signing code, you can delete most of it now. The Azure SDK for Rust reached general availability in mid-2026, shipping stable 1.0 crates for Core, Identity, Key Vault, and Storage. This guide walks you through replacing raw REST access with the official clients: adding the crates, wiring up &lt;code&gt;DefaultAzureCredential&lt;/code&gt;, reading a Key Vault secret, downloading a blob, and turning on retries and tracing. Every step maps a piece of REST plumbing you can retire to the typed client that replaces it.&lt;/p&gt;

&lt;p&gt;The migration is not a rewrite. The clients follow the same design patterns as the .NET, Python, Go, and Java SDKs, so the shapes are predictable. What changes is that authentication, retries, and pagination stop being your problem and become the SDK's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually went GA
&lt;/h2&gt;

&lt;p&gt;The GA wave promoted a specific set of crates to stable 1.0. Knowing which ones are production-ready keeps you from pinning a preview crate by accident:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Crate&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_core&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared pipeline: HTTP, retries, auth traits, error types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_identity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Credential types, including &lt;code&gt;DefaultAzureCredential&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_security_keyvault_secrets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key Vault secrets client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_security_keyvault_keys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key Vault keys client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_security_keyvault_certificates&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Key Vault certificates client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_storage_blob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blob upload, download, and container operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_storage_queue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Queue send and receive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;azure_core_opentelemetry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Distributed tracing bridge for the pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things did not make the GA cut, and you should plan around them. Event Hubs is slated for the next stable wave, and Cosmos DB support is in active development with a stable release expected later in 2026. If your service depends on either, keep your existing REST or preview code for those paths and migrate the rest now. Microsoft's &lt;a href="https://learn.microsoft.com/en-us/azure/developer/rust/sdk/overview" rel="noopener noreferrer"&gt;Rust on Azure overview&lt;/a&gt; tracks the current crate status if you want to confirm before you pin a version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: add the crates
&lt;/h2&gt;

&lt;p&gt;Start with a clean dependency set. From your crate root, let Cargo resolve the latest stable versions rather than guessing patch numbers:&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="nv"&gt;$ &lt;/span&gt;cargo add azure_identity azure_security_keyvault_secrets azure_storage_blob tokio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pulls &lt;code&gt;azure_core&lt;/code&gt; transitively, so you rarely add it by hand. If you prefer to pin versions explicitly, your &lt;code&gt;Cargo.toml&lt;/code&gt; ends up looking like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;azure_identity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;
&lt;span class="py"&gt;azure_security_keyvault_secrets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;
&lt;span class="py"&gt;azure_storage_blob&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;
&lt;span class="py"&gt;azure_core&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0"&lt;/span&gt;
&lt;span class="py"&gt;tokio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"full"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK is async-first and expects a Tokio runtime, which is why &lt;code&gt;tokio&lt;/code&gt; is in the list with the &lt;code&gt;full&lt;/code&gt; feature. If your service already runs on &lt;code&gt;async-std&lt;/code&gt; or a custom runtime, you will need a compatibility shim, because the clients are built and tested against Tokio.&lt;/p&gt;

&lt;p&gt;Verify the tree resolved cleanly before you write any client code:&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="nv"&gt;$ &lt;/span&gt;cargo tree &lt;span class="nt"&gt;-p&lt;/span&gt; azure_core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a single &lt;code&gt;azure_core 1.x&lt;/code&gt; in the output. If two versions show up, one of your other Azure crates is still on a preview release, and mixing a 1.0 core with a 0.x client is the most common source of trait-mismatch errors during migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: replace your auth code with azure_identity
&lt;/h2&gt;

&lt;p&gt;This is where you delete the most code. If your REST client was fetching tokens from the IMDS endpoint or juggling a client secret from an environment variable, &lt;code&gt;DefaultAzureCredential&lt;/code&gt; replaces all of it with one type that tries a chain of sources in order: environment variables, workload identity, managed identity, and your local developer sign-in.&lt;/p&gt;

&lt;p&gt;Here is the full pattern for constructing a credential and handing it to a client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_identity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_security_keyvault_secrets&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SecretClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SecretClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"https://your-vault-name.vault.azure.net/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// client is now ready to make authenticated calls&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;credential.clone()&lt;/code&gt; is cheap. Credentials are reference-counted internally, so cloning shares the same token cache rather than re-authenticating. Build one credential at startup and clone it into every client you construct.&lt;/p&gt;

&lt;p&gt;For local development, &lt;code&gt;DefaultAzureCredential&lt;/code&gt; will pick up the identity you signed in with through &lt;code&gt;az login&lt;/code&gt;. Confirm that works before you run anything:&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="nv"&gt;$ &lt;/span&gt;az login
&lt;span class="nv"&gt;$ &lt;/span&gt;az account show &lt;span class="nt"&gt;--query&lt;/span&gt; user.name &lt;span class="nt"&gt;-o&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to be explicit about using developer tooling and skip the managed-identity probes (which add latency and noisy log lines when you run locally), swap in &lt;code&gt;DeveloperToolsCredential&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_identity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DeveloperToolsCredential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DeveloperToolsCredential&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on scope. In production, prefer managed identity so there is no secret to leak, and grant the identity only the specific Key Vault and Storage roles it needs. The same discipline that keeps &lt;a href="https://dev.to/blog/github-actions-security-how-to-stop-secret-leaks-in-cicd"&gt;secrets out of your CI pipelines&lt;/a&gt; applies here: the fewer long-lived credentials your Rust service holds, the smaller your blast radius. If you run across several Azure subscriptions, the &lt;a href="https://dev.to/tips/how-to-manage-multiple-azure-subscriptions-in-terraform"&gt;multi-subscription patterns from the Terraform side&lt;/a&gt; carry over, because &lt;code&gt;DefaultAzureCredential&lt;/code&gt; honors the same &lt;code&gt;AZURE_SUBSCRIPTION_ID&lt;/code&gt; and tenant environment variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: migrate a Key Vault secret read
&lt;/h2&gt;

&lt;p&gt;A typical pre-SDK secret fetch was a signed GET against &lt;code&gt;https://your-vault.vault.azure.net/secrets/{name}?api-version=7.4&lt;/code&gt;, plus JSON parsing to dig the value out of the response envelope. Replace the whole thing with a typed call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_identity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_security_keyvault_secrets&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;SecretClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;SecretClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"https://your-vault-name.vault.azure.net/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;
        &lt;span class="nf"&gt;.get_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"database-password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.into_body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="py"&gt;.value&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secret length: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter here. First, the empty string for the version argument means "current version," which is what you almost always want. Pass a specific version string only when you need to pin to a historical value. Second, the response is a two-stage unwrap: &lt;code&gt;.await?&lt;/code&gt; gives you the HTTP response, and &lt;code&gt;.into_body().await?&lt;/code&gt; deserializes it into the typed &lt;code&gt;Secret&lt;/code&gt; model. Third, never log the secret value itself. Print its length or a hash if you need a sanity check, as the example does above.&lt;/p&gt;

&lt;p&gt;Run it with your vault name substituted in:&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="nv"&gt;$ &lt;/span&gt;cargo run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get a 403, the problem is almost always RBAC rather than code. Grant your identity the Key Vault Secrets User role:&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="nv"&gt;$ &lt;/span&gt;az role assignment create &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"Key Vault Secrets User"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--assignee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad signed-in-user show &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"/subscriptions/&amp;lt;sub-id&amp;gt;/resourceGroups/&amp;lt;rg&amp;gt;/providers/Microsoft.KeyVault/vaults/&amp;lt;vault-name&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://learn.microsoft.com/en-us/azure/developer/rust/sdk/authentication/overview" rel="noopener noreferrer"&gt;authentication overview on Microsoft Learn&lt;/a&gt; documents the full credential chain and which environment variables each link reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: migrate blob storage access
&lt;/h2&gt;

&lt;p&gt;Blob access follows the same construction pattern. You build a client against the account URL, then operate on containers and blobs. Here is a download that streams the blob body into memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_identity&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_storage_blob&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;BlobClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;error&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;blob_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;BlobClient&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"https://yourstorageaccount.blob.core.windows.net/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"reports"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s"&gt;"2026-q3.json"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;blob_client&lt;/span&gt;&lt;span class="nf"&gt;.download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.into_body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"downloaded {} bytes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constructor arguments are the account URL, the container name, the blob name, the credential, and an options struct. Passing &lt;code&gt;None&lt;/code&gt; for options accepts the defaults, which is the right starting point. The &lt;code&gt;download&lt;/code&gt; call returns a response whose body you collect into bytes. For large blobs you would stream chunks rather than collecting the whole body, but collecting is fine for config files and small artifacts.&lt;/p&gt;

&lt;p&gt;The same authorization rule applies: the identity needs the Storage Blob Data Reader role (or Contributor if you also write). Assign it the same way you did for Key Vault, swapping the role name and the scope to your storage account.&lt;/p&gt;

&lt;p&gt;Uploading is the mirror image. You build the same client and hand it the bytes plus a length:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_core&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RequestContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;b"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;generated_at&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;2026-07-31&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;}"&lt;/span&gt;&lt;span class="nf"&gt;.to_vec&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;blob_client&lt;/span&gt;
    &lt;span class="nf"&gt;.upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;RequestContent&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;len&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boolean argument is the overwrite flag. Passing &lt;code&gt;true&lt;/code&gt; replaces an existing blob of the same name, and &lt;code&gt;false&lt;/code&gt; fails the call if the blob already exists, which is the safer default when you are writing an object that should be created exactly once. As with the download, the options struct is &lt;code&gt;None&lt;/code&gt; until you need to set content type, metadata, or an access tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: retries and resilience are already on
&lt;/h2&gt;

&lt;p&gt;One of the quieter wins in the GA release is that resilience is built into &lt;code&gt;azure_core&lt;/code&gt;'s pipeline and on by default. Transient failures (HTTP 429, 503, and connection resets) are retried automatically with exponential backoff. You do not write retry loops anymore, and you should delete any you carried over from your REST client, because doubling up on retries turns a brief throttle into a much longer stall.&lt;/p&gt;

&lt;p&gt;When you do need to tune the behavior, you set it through the client options struct rather than wrapping calls yourself. The pattern looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_core&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RetryOptions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Construct retry options and pass them through the client's options struct&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;retry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;RetryOptions&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;exponential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GA release also added challenge-based authentication, so the clients work correctly in sovereign and private cloud environments where the token audience is discovered from a challenge response rather than assumed. If you previously special-cased Azure Government or a private cloud, that branch can likely go.&lt;/p&gt;

&lt;p&gt;Start with the defaults. The built-in policy is tuned for the common case, and premature retry tuning is a frequent way to make throttling worse rather than better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: turn on distributed tracing
&lt;/h2&gt;

&lt;p&gt;If your service already emits OpenTelemetry spans, you can thread Azure SDK calls into the same traces using the &lt;code&gt;azure_core_opentelemetry&lt;/code&gt; crate. It bridges the SDK's internal pipeline instrumentation to your OpenTelemetry tracer, so every Key Vault or Storage call shows up as a child span under your request.&lt;/p&gt;

&lt;p&gt;Add the crate:&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="nv"&gt;$ &lt;/span&gt;cargo add azure_core_opentelemetry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You wire it in by attaching the OpenTelemetry tracer provider to the client options, after which HTTP calls the SDK makes are recorded as spans with the operation name, target, and status. The HTTP logging layer sanitizes secrets by default, so authorization headers and secret values are redacted before anything reaches your log sink. That default matters: it means turning on verbose SDK logging during an incident will not accidentally dump a Key Vault secret into your log aggregator.&lt;/p&gt;

&lt;p&gt;If you are new to wiring OpenTelemetry through an application, the mechanics of tracer setup and exporters are the same ones covered in this walkthrough on &lt;a href="https://dev.to/tutorials/how-to-set-up-llm-observability-with-opentelemetry"&gt;setting up observability with OpenTelemetry&lt;/a&gt;; the Azure crate simply feeds the SDK's own spans into that pipeline instead of you instrumenting each call by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle errors with the typed error model
&lt;/h2&gt;

&lt;p&gt;Your REST client probably branched on raw status codes pulled out of a response struct. The SDK gives you a typed &lt;code&gt;azure_core::Error&lt;/code&gt; instead, and the useful move during migration is to inspect its HTTP status when you need to distinguish a genuine "not found" from a transient failure the pipeline already gave up retrying.&lt;/p&gt;

&lt;p&gt;A common case is treating a missing secret as an expected outcome rather than a hard failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;azure_core&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="nf"&gt;.get_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"optional-flag"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.into_body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"found: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="py"&gt;.value&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_or_default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.http_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secret not set, using default"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.into&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern is worth internalizing because it is identical across every GA client. A Key Vault 404, a Storage 404, and an Identity failure all surface through the same &lt;code&gt;azure_core::Error&lt;/code&gt; type with the same &lt;code&gt;http_status()&lt;/code&gt; accessor. That consistency is a large part of why migrating the second and third service is faster than the first: once you have written error handling for one client, you have written it for all of them.&lt;/p&gt;

&lt;p&gt;Resist the urge to match on error strings. The status accessor is stable across releases; the human-readable message is not, and matching on it will break the next time the wire format changes a word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying to AKS with workload identity
&lt;/h2&gt;

&lt;p&gt;The payoff of &lt;code&gt;DefaultAzureCredential&lt;/code&gt; shows up in production, where you want zero secrets in the container. On Azure Kubernetes Service, workload identity federates your pod's service account to an Azure managed identity, and the credential picks it up automatically through environment variables the workload-identity webhook injects. Your Rust code does not change at all between laptop and cluster, which is the point.&lt;/p&gt;

&lt;p&gt;The cluster-side wiring is three annotations and a federated credential:&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="nv"&gt;$ &lt;/span&gt;az identity federated-credential create &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--name&lt;/span&gt; rust-app-federated &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--identity-name&lt;/span&gt; rust-app-identity &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &amp;lt;rg&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--issuer&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az aks show &lt;span class="nt"&gt;-g&lt;/span&gt; &amp;lt;rg&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;cluster&amp;gt; &lt;span class="nt"&gt;--query&lt;/span&gt; oidcIssuerProfile.issuerUrl &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"system:serviceaccount:&amp;lt;namespace&amp;gt;:&amp;lt;service-account&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the federated credential exists and your pod's service account carries the &lt;code&gt;azure.workload.identity/client-id&lt;/code&gt; annotation, the same binary you tested locally authenticates as the managed identity with no code path difference. Grant that identity the same Key Vault and Storage roles you used during local testing, scoped to production resources, and you have a service with no long-lived credential anywhere in the deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  A migration checklist
&lt;/h2&gt;

&lt;p&gt;Work through your codebase in this order to keep the change reviewable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inventory every place you call an Azure REST endpoint from Rust. Group them by service (Key Vault, Storage, and anything not yet GA).&lt;/li&gt;
&lt;li&gt;Add the GA crates for the services you found, and run &lt;code&gt;cargo tree&lt;/code&gt; to confirm a single &lt;code&gt;azure_core 1.x&lt;/code&gt; in the graph.&lt;/li&gt;
&lt;li&gt;Replace token acquisition with a single &lt;code&gt;DefaultAzureCredential&lt;/code&gt; built at startup and cloned into each client.&lt;/li&gt;
&lt;li&gt;Convert one service at a time. Migrate Key Vault first, since it is usually the smallest surface, then Storage.&lt;/li&gt;
&lt;li&gt;Delete your hand-written retry loops and header-signing helpers once the typed client covers that path.&lt;/li&gt;
&lt;li&gt;Leave Event Hubs and Cosmos DB on your existing code until their stable crates ship, and tag those spots with a comment so you remember to revisit.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;azure_core_opentelemetry&lt;/code&gt; last, once functionality is proven, so tracing reflects the new call paths.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Migrating incrementally like this is the same principle behind any staged platform move, including the &lt;a href="https://dev.to/blog/azure-devops-to-github-migration-ai-driven-playbook"&gt;Azure DevOps to GitHub playbook&lt;/a&gt;: change one bounded surface, verify it in production, then move to the next. Do not try to flip every service in a single pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common migration gotchas
&lt;/h2&gt;

&lt;p&gt;A few things trip people up on the first pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mixed crate versions.&lt;/strong&gt; A preview &lt;code&gt;0.x&lt;/code&gt; client against a &lt;code&gt;1.0&lt;/code&gt; &lt;code&gt;azure_core&lt;/code&gt; produces confusing trait errors. Pin everything to the 1.0 line and re-run &lt;code&gt;cargo tree&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;.into_body()&lt;/code&gt;.&lt;/strong&gt; The first &lt;code&gt;.await?&lt;/code&gt; gives you the response, not the parsed model. The typed value comes from the second &lt;code&gt;.into_body().await?&lt;/code&gt;. Skipping it is a frequent compile-time confusion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuilding credentials per request.&lt;/strong&gt; Constructing &lt;code&gt;DefaultAzureCredential&lt;/code&gt; inside a request handler defeats the token cache and adds latency. Build once, clone many.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC, not code.&lt;/strong&gt; A 401 usually means the audience or tenant is wrong; a 403 almost always means a missing role assignment. Check &lt;code&gt;az role assignment list&lt;/code&gt; before you suspect the SDK.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;After this migration, your Azure access code in Rust is smaller, typed, and consistent with how the rest of your Azure fleet is written in other languages. Authentication is one credential built at startup. Retries and secret redaction are handled by the pipeline. Tracing plugs into the OpenTelemetry setup you already run. The two gaps to watch are Event Hubs and Cosmos DB, both of which have stable crates on the roadmap, so keep those integration points isolated and ready to swap.&lt;/p&gt;

&lt;p&gt;Start with a single non-critical service, prove the pattern end to end in a staging environment, and use the checklist above to roll it out service by service. The &lt;a href="https://learn.microsoft.com/en-us/azure/developer/rust/sdk/overview" rel="noopener noreferrer"&gt;crate status page on Microsoft Learn&lt;/a&gt; is worth a bookmark, because the GA surface is still expanding and the next wave will let you delete even more REST plumbing.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>security</category>
      <category>observability</category>
      <category>opentelemetry</category>
    </item>
    <item>
      <title>Manage OTel Collectors at Scale with OpAMP</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Tue, 28 Jul 2026 09:56:39 +0000</pubDate>
      <link>https://dev.to/devopsstart/manage-otel-collectors-at-scale-with-opamp-468h</link>
      <guid>https://dev.to/devopsstart/manage-otel-collectors-at-scale-with-opamp-468h</guid>
      <description>&lt;p&gt;If you run more than a handful of OpenTelemetry Collectors, you already know the pain: a config change means SSHing into boxes, redeploying DaemonSets, or babysitting a Git pipeline per cluster, and you never quite trust that every agent is running the config you think it is. OpAMP fixes exactly that. It is a protocol that lets a central server push configuration to a fleet of Collectors, watch their health, and roll changes out in stages, without you touching each host. This post walks through how OpAMP works, the two ways a Collector can speak it, and the config you need to wire one up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem OpAMP solves
&lt;/h2&gt;

&lt;p&gt;A single Collector is easy. A hundred of them, spread across clusters, VMs, and edge nodes, is a fleet-management problem that has nothing to do with telemetry itself. Every observability team eventually builds some version of the same thing: a way to ship a new pipeline config, confirm it actually applied, and back it out when a processor starts dropping spans.&lt;/p&gt;

&lt;p&gt;Without a management protocol you end up gluing that together from ConfigMaps, Ansible runs, and dashboards that only tell you an agent is alive, not what config it is actually running. Config drift creeps in. One node keeps an old sampling rate for months because its rollout quietly failed and nobody noticed.&lt;/p&gt;

&lt;p&gt;OpAMP, the Open Agent Management Protocol, is the OpenTelemetry answer to this. Splunk donated it to the project in 2022, and it has since become the standard control channel for the Collector. It is worth pairing with a clear-eyed view of what a Collector actually is versus lighter agents; the &lt;a href="https://dev.to/comparisons/opentelemetry-collector-vs-grafana-alloy-2026-guide"&gt;OpenTelemetry Collector vs Grafana Alloy comparison&lt;/a&gt; covers that trade-off if you are still choosing a data plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OpAMP actually is
&lt;/h2&gt;

&lt;p&gt;OpAMP is a client/server network protocol for remote management of large fleets of data-collection agents. It is transport-flexible: agents connect to the server over either plain HTTP or a WebSocket, and the WebSocket path gives you a persistent bidirectional channel so the server can push a new config the moment you save it.&lt;/p&gt;

&lt;p&gt;The protocol is deliberately narrow. It handles a specific set of jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Remote configuration&lt;/strong&gt;: the server sends a config, the agent applies it and reloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health and status reporting&lt;/strong&gt;: agents report whether they are healthy and what they are doing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Effective config reporting&lt;/strong&gt;: agents send back the config they are actually running, so you can detect drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own-telemetry reporting&lt;/strong&gt;: agents can stream their own metrics, logs, and traces about themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package and version management&lt;/strong&gt;: the server can discover an agent's version and, optionally, push binary updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what is not in that list: OpAMP does not define what your telemetry pipeline looks like. It carries an opaque config blob to the agent and lets the agent decide what to do with it. For a Collector, that blob is just your normal Collector YAML. OpAMP is the envelope, not the letter.&lt;/p&gt;

&lt;p&gt;That separation is the whole design. The &lt;a href="https://opentelemetry.io/docs/collector/management/" rel="noopener noreferrer"&gt;OpenTelemetry management docs&lt;/a&gt; describe OpAMP as the recommended path for fleet management, and the protocol spec is published at &lt;a href="https://opentelemetry.io/docs/specs/opamp/" rel="noopener noreferrer"&gt;opentelemetry.io/docs/specs/opamp&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways a Collector speaks OpAMP
&lt;/h2&gt;

&lt;p&gt;There are two distinct integration points, and mixing them up is the most common early mistake.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;What it manages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;opamp&lt;/code&gt; extension&lt;/td&gt;
&lt;td&gt;An extension compiled into the Collector&lt;/td&gt;
&lt;td&gt;Reports health, effective config, and identity to the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpAMP Supervisor&lt;/td&gt;
&lt;td&gt;A separate process that wraps the Collector&lt;/td&gt;
&lt;td&gt;Full lifecycle: applies remote config, restarts, and reports on the Collector's behalf&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The built-in &lt;code&gt;opamp&lt;/code&gt; extension is the lightweight option. It lets a Collector announce itself to an OpAMP server and report status, but the extension alone cannot rewrite the Collector's config and restart it, because a running process cannot swap out the config file it booted from and cleanly reload every component.&lt;/p&gt;

&lt;p&gt;The Supervisor closes that gap. It is a small parent process that launches the Collector as a child, holds the OpAMP connection itself, and owns the Collector's lifecycle. When a new config arrives, the Supervisor writes it to disk, restarts the Collector against it, and reports the result upstream. For actual remote-configuration-driven fleet management, the Supervisor is the path you want. It lives in the &lt;code&gt;opentelemetry-collector-contrib&lt;/code&gt; repository under &lt;code&gt;cmd/opampsupervisor&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring up the Supervisor
&lt;/h2&gt;

&lt;p&gt;The Supervisor takes its own config file, usually &lt;code&gt;supervisor.yaml&lt;/code&gt;, which is separate from the Collector config it manages. Here is a representative example:&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;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wss://opamp.example.com:4320/v1/opamp&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;insecure_skip_verify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;accepts_remote_config&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;reports_effective_config&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;reports_own_metrics&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;reports_own_logs&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;reports_health&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;reports_remote_config&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;agent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;executable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/otelcol-contrib&lt;/span&gt;
&lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/otelcol-supervisor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three blocks matter here.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;server&lt;/code&gt; block points at your OpAMP backend. The &lt;code&gt;wss://&lt;/code&gt; scheme selects the WebSocket transport, and &lt;code&gt;4320&lt;/code&gt; is the conventional OpAMP port used across the project's examples. Keep &lt;code&gt;insecure_skip_verify&lt;/code&gt; at &lt;code&gt;false&lt;/code&gt; in anything real; you are opening a control channel that can change what runs on your hosts, so certificate verification is not optional.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;capabilities&lt;/code&gt; block is a set of explicit opt-ins. Nothing is implied. If you want the server to be able to push config, you must set &lt;code&gt;accepts_remote_config: true&lt;/code&gt;. If you want drift detection, &lt;code&gt;reports_effective_config: true&lt;/code&gt; is what sends the running config back. Turning these on individually means you can start conservative (health only) and add remote config later once you trust the setup.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;agent&lt;/code&gt; block tells the Supervisor which binary to run and manage, and &lt;code&gt;storage&lt;/code&gt; is where it persists the last-known remote config so a restart does not lose it.&lt;/p&gt;

&lt;p&gt;You start the Supervisor, not the Collector, and let it own the child process:&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="nv"&gt;$ &lt;/span&gt;otelcol-supervisor &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/otelcol/supervisor.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here on you never start the Collector directly. The Supervisor connects to the server, sends an &lt;code&gt;AgentDescription&lt;/code&gt; that identifies this instance, and waits for config. When you push a new pipeline from the server, the Supervisor lands it on disk and cycles the Collector.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a remote config flow looks like
&lt;/h2&gt;

&lt;p&gt;Once an agent is connected with &lt;code&gt;accepts_remote_config&lt;/code&gt; enabled, the loop is simple to reason about:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You edit a Collector config in the server's UI or API and target a set of agents.&lt;/li&gt;
&lt;li&gt;The server sends the config over the open connection.&lt;/li&gt;
&lt;li&gt;The Supervisor writes it to its storage directory and restarts the Collector against it.&lt;/li&gt;
&lt;li&gt;The Collector boots, and the Supervisor reports back the new effective config and health.&lt;/li&gt;
&lt;li&gt;The server marks the rollout applied for that agent, or surfaces an error if the Collector rejected the config.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the part teams underrate. Because the agent reports its &lt;em&gt;effective&lt;/em&gt; config, you get a closed loop: the server does not just assume the push worked, it sees the config the Collector is genuinely running. That is how you catch the node that silently kept an old sampling rate. A dashboard built on &lt;code&gt;reports_effective_config&lt;/code&gt; shows you real drift instead of a green checkmark that means nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health, identity, and self-telemetry
&lt;/h2&gt;

&lt;p&gt;The reporting capabilities are worth turning on even before you trust remote config. With &lt;code&gt;reports_health: true&lt;/code&gt;, each agent tells the server whether it is up and functioning, which beats inferring liveness from whether metrics happen to be flowing. Health here means the Collector's own view of itself, including whether its pipelines started cleanly.&lt;/p&gt;

&lt;p&gt;Identity comes from the &lt;code&gt;AgentDescription&lt;/code&gt; message. Every connecting agent sends a set of attributes about itself: hostname, OS, Collector version, and any custom labels you attach. Those labels are the backbone of fleet management, because they are how you target a subset of agents. You push a config to &lt;code&gt;service.namespace=payments&lt;/code&gt; and only those Collectors receive it. Getting your labeling scheme right early is more important than the config content itself; without good labels, every rollout is all-or-nothing.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;reports_own_metrics: true&lt;/code&gt;, the Collector streams its internal metrics (queue sizes, dropped spans, export failures) as part of the same channel. Feed those into your existing metrics backend. If you scrape them with Prometheus, the &lt;a href="https://prometheus.io/docs/introduction/overview/" rel="noopener noreferrer"&gt;Prometheus documentation&lt;/a&gt; covers the receiver side, and Grafana's own agent tooling documented at &lt;a href="https://grafana.com/docs/" rel="noopener noreferrer"&gt;grafana.com&lt;/a&gt; shows how a similar management model looks in a different distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Package management, and why to be careful
&lt;/h2&gt;

&lt;p&gt;OpAMP can also push binary updates. The server can discover an agent's version and, with the right capability enabled, deliver a new package so the agent upgrades or downgrades itself. On paper this is the dream: patch a Collector CVE across a thousand hosts from one console.&lt;/p&gt;

&lt;p&gt;In practice, treat auto-update as the most dangerous capability in the protocol and turn it on last. A bad config push restarts a Collector; a bad package push replaces the binary on every targeted host at once. Stage it the way you would any other production rollout: a canary group first, watch health and effective-config reporting, then widen. The protocol gives you the mechanism, not the judgment. Keep package management off until your health and config feedback loops are boringly reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling out across a real fleet
&lt;/h2&gt;

&lt;p&gt;Scale is where the labeling discipline pays off. A sane rollout pattern looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tag everything.&lt;/strong&gt; Attach environment, region, and service labels to every agent via its identifying attributes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary by label.&lt;/strong&gt; Push a config change to a small, clearly labeled canary group first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch effective config.&lt;/strong&gt; Confirm the canary agents report the new config as their effective config, not just that they acknowledged the push.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch health.&lt;/strong&gt; Give it long enough to catch a pipeline that starts fine but fails under load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Widen in waves.&lt;/strong&gt; Expand to the next label group, then the rest, with the same two checks each time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the same staged-rollout thinking you would apply to a Kubernetes deployment, and it composes well with cost and reliability work already in your pipeline. If you are instrumenting application workloads at the same time, the collector fleet you manage with OpAMP is what those pipelines feed into; see &lt;a href="https://dev.to/tutorials/how-to-set-up-llm-observability-with-opentelemetry"&gt;How to Set Up LLM Observability with OpenTelemetry&lt;/a&gt; and, for cluster-scale patterns, &lt;a href="https://dev.to/tutorials/llm-observability-on-kubernetes-a-practical-guide"&gt;LLM Observability on Kubernetes&lt;/a&gt; for the data-plane side of the same system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking a server
&lt;/h2&gt;

&lt;p&gt;OpAMP is only half a system; you also need a server that speaks it. You have two routes. You can run a managed or open-source OpAMP backend such as BindPlane, which grew out of the same observIQ work that seeded the protocol, and get a UI, agent inventory, and config management out of the box. Or you can build against &lt;code&gt;opamp-go&lt;/code&gt;, the reference server and client implementation, if you want the control channel wired directly into your own platform.&lt;/p&gt;

&lt;p&gt;For most teams, starting with an existing server is the right call. The value of OpAMP is the fleet view and the safe rollout mechanics, and rebuilding those from the reference libraries is a real engineering investment. Start managed, learn the operational patterns, and only build your own server if you have a platform reason the off-the-shelf options cannot meet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;OpAMP turns a pile of independently configured Collectors into a fleet you can actually operate. The mental model is small: the Supervisor owns the Collector's lifecycle, the &lt;code&gt;capabilities&lt;/code&gt; block is a set of explicit opt-ins, and effective-config reporting is what makes the whole thing trustworthy instead of hopeful. Start with health and effective-config reporting so you can see your fleet, add remote config once you trust the feedback loop, and leave package auto-update for last.&lt;/p&gt;

&lt;p&gt;If you are still deciding whether the full Collector is even the right data plane for your fleet, settle that first, then bring OpAMP in to manage whatever you land on. The protocol is agnostic about the pipeline; it just makes running a lot of them survivable.&lt;/p&gt;

</description>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>monitoring</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>Validate Kubernetes Manifests with Flux Schema</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sun, 26 Jul 2026 09:26:07 +0000</pubDate>
      <link>https://dev.to/devopsstart/validate-kubernetes-manifests-with-flux-schema-4kbh</link>
      <guid>https://dev.to/devopsstart/validate-kubernetes-manifests-with-flux-schema-4kbh</guid>
      <description>&lt;p&gt;If you run GitOps with Flux, a broken manifest usually gets caught the slow way: it merges, the reconciler chokes, and you find out from a failing Kustomization. Flux Schema, the plugin that shipped with Flux 2.9, moves that check left into CI. It validates every YAML document against JSON Schema and CEL rules using the same evaluation logic as the Kubernetes API server, so a bad field fails the pull request instead of the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and run it
&lt;/h2&gt;

&lt;p&gt;Flux Schema is a CLI plugin, not part of the core binary. Install it through the plugin system:&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="nv"&gt;$ &lt;/span&gt;flux plugin &lt;span class="nb"&gt;install &lt;/span&gt;schema
&lt;span class="nv"&gt;$ &lt;/span&gt;flux schema &lt;span class="nt"&gt;--help&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin a version in CI so a new release never changes your gate's behavior mid-sprint:&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="nv"&gt;$ &lt;/span&gt;flux plugin &lt;span class="nb"&gt;install &lt;/span&gt;schema@0.5.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point it at a directory of manifests and it validates each document:&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="nv"&gt;$ &lt;/span&gt;flux schema validate ./manifests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ships with built-in schemas for Kubernetes, OpenShift, Gateway API, and the Flux CRDs, so a fresh install already knows your &lt;code&gt;HelmRelease&lt;/code&gt; and &lt;code&gt;Kustomization&lt;/code&gt; kinds without any setup. Strict validation flags unknown fields, wrong types, and missing required properties as hard errors, which catches the typos &lt;code&gt;kubectl apply --dry-run=client&lt;/code&gt; quietly ignores.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CEL adds over plain schema checks
&lt;/h2&gt;

&lt;p&gt;JSON Schema catches shape problems: a string where an int belongs, a misspelled key. CEL rules catch logic problems. Because Flux Schema runs the &lt;code&gt;x-kubernetes-validations&lt;/code&gt; rules embedded in CRDs through the same CEL engine the API server uses, a manifest that violates a cross-field constraint (say, a replica count that must stay below a limit, or two mutually exclusive fields both set) fails in CI with the exact message the cluster would have returned. You are testing against the real admission logic, not a stale copy of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into a config file
&lt;/h2&gt;

&lt;p&gt;Drop a &lt;code&gt;.fluxschema.yml&lt;/code&gt; at your repo root to control what gets checked. The file uses the &lt;code&gt;schema.plugin.fluxcd.io/v1beta1&lt;/code&gt; API and a &lt;code&gt;Config&lt;/code&gt; kind:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;schema.plugin.fluxcd.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Config&lt;/span&gt;
&lt;span class="na"&gt;skipKind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;span class="na"&gt;skipJSONPath&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;$.metadata.annotations"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;skipKind&lt;/code&gt;, &lt;code&gt;skipFile&lt;/code&gt;, and &lt;code&gt;skipJSONPath&lt;/code&gt; let you exclude the things that legitimately fail strict checks, like sealed secrets or generated fields. Then the command reads it automatically:&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="nv"&gt;$ &lt;/span&gt;flux schema validate ./manifests &lt;span class="nt"&gt;--config&lt;/span&gt; .fluxschema.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Put it in the pull request gate
&lt;/h2&gt;

&lt;p&gt;The real payoff is in CI. Flux Schema ships two composite GitHub Actions: &lt;code&gt;setup&lt;/code&gt; installs the CLI on the runner, and &lt;code&gt;validate&lt;/code&gt; auto-detects your kustomize overlays, renders them, and validates every rendered document. A minimal gate looks like this:&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;validate-manifests&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&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;flux-schema&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;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;fluxcd/flux-schema/actions/validate@main&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;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.fluxschema.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the Ecosystem Catalog behind the plugin refreshes daily from upstream releases, your CI validates against the current API versions rather than whatever was frozen months ago. That matters most right after a Kubernetes minor bump, when a deprecated field you have used for a year suddenly needs to change.&lt;/p&gt;

&lt;p&gt;One habit worth keeping: run &lt;code&gt;flux schema validate&lt;/code&gt; locally before you push, not just in CI. The feedback loop is a second or two, and it saves a round trip through the runner. If you are still deciding between Flux and Argo CD for this kind of workflow, our &lt;a href="https://dev.to/blog/argo-cd-vs-flux-a-guide-for-multi-cluster-gitops"&gt;Argo CD vs Flux guide&lt;/a&gt; compares them for multi-cluster setups, and our &lt;a href="https://dev.to/blog/gitops-testing-strategies-validate-deployments-with-argocd"&gt;GitOps testing strategies&lt;/a&gt; piece covers where manifest validation fits in a broader test pyramid.&lt;/p&gt;

&lt;p&gt;Read the &lt;a href="https://fluxcd.io/blog/2026/07/flux-schema-validation/" rel="noopener noreferrer"&gt;Flux Schema announcement on fluxcd.io&lt;/a&gt; for the full catalog details, then add the action to one repo and watch the first bad manifest fail its PR instead of your cluster.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>flux</category>
    </item>
    <item>
      <title>Fix GitLab CI "dial tcp: lookup docker" no such host error</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sat, 25 Jul 2026 09:13:31 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-gitlab-ci-dial-tcp-lookup-docker-no-such-host-error-3913</link>
      <guid>https://dev.to/devopsstart/fix-gitlab-ci-dial-tcp-lookup-docker-no-such-host-error-3913</guid>
      <description>&lt;h2&gt;
  
  
  The fast fix
&lt;/h2&gt;

&lt;p&gt;If your GitLab CI job dies with &lt;code&gt;error during connect: ... dial tcp: lookup docker on 127.0.0.11:53: no such host&lt;/code&gt;, your &lt;code&gt;docker&lt;/code&gt; client resolved &lt;code&gt;DOCKER_HOST&lt;/code&gt; fine but the DNS name &lt;code&gt;docker&lt;/code&gt; does not exist on the job's network. That name is the alias of the &lt;code&gt;docker:dind&lt;/code&gt; service, and it only registers when the service container actually starts. The usual fix is to define the service with the &lt;code&gt;-dind&lt;/code&gt; image tag and an explicit alias, and point &lt;code&gt;DOCKER_HOST&lt;/code&gt; at TLS port 2376:&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;build&lt;/span&gt;&lt;span class="pi"&gt;:&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;docker:28.3&lt;/span&gt;
  &lt;span class="na"&gt;services&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;docker:28.3-dind&lt;/span&gt;
      &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2376&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs"&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_CERT_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs/client"&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_VERIFY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker info&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t my-app .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers the common case on the &lt;code&gt;docker&lt;/code&gt; executor. If you are on the Kubernetes executor, or the service still refuses to resolve, keep reading. The name resolution is the whole game here, and there are three distinct reasons it fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this error is not the daemon-connection error
&lt;/h2&gt;

&lt;p&gt;The address in the message tells you exactly how far the client got. This DNS variant is different from the two connection variants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error during connect: Post "https://docker:2376/v1.44/info":
dial tcp: lookup docker on 127.0.0.11:53: no such host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client tried to resolve the hostname &lt;code&gt;docker&lt;/code&gt; through the container DNS resolver (&lt;code&gt;127.0.0.11&lt;/code&gt; on a Docker bridge network) and got back nothing. That is a name-resolution failure, not a refused connection. Compare it to the &lt;code&gt;tcp://docker:2375&lt;/code&gt; form, where the name resolves but the daemon is unreachable, covered in the &lt;a href="https://dev.to/troubleshooting/fix-gitlab-ci-docker-daemon-connection-error-in-3-steps"&gt;Docker daemon connection error write-up&lt;/a&gt;, and the &lt;code&gt;unix:///var/run/docker.sock&lt;/code&gt; form, where &lt;code&gt;DOCKER_HOST&lt;/code&gt; was never set at all, covered in &lt;a href="https://dev.to/troubleshooting/gitlab-ci-cannot-connect-unix-var-run-docker-sock"&gt;the socket-variant fix&lt;/a&gt;. If your message says &lt;code&gt;no such host&lt;/code&gt;, the client never reached any daemon because the name pointed at nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three real causes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The dind service never started, so its alias never registered
&lt;/h3&gt;

&lt;p&gt;This is the most common cause and the least obvious. GitLab registers the &lt;code&gt;docker&lt;/code&gt; alias on the build network only when the &lt;code&gt;docker:dind&lt;/code&gt; service container comes up. If that container fails to start, the alias is missing and every lookup returns &lt;code&gt;no such host&lt;/code&gt;. Three things stop it from starting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You used the plain image, not the &lt;code&gt;-dind&lt;/code&gt; tag.&lt;/strong&gt; &lt;code&gt;docker:28.3&lt;/code&gt; ships only the client. You need &lt;code&gt;docker:28.3-dind&lt;/code&gt;, which bundles &lt;code&gt;dockerd&lt;/code&gt;. A plain &lt;code&gt;docker&lt;/code&gt; service starts, exits immediately (no daemon to run), and takes its alias down with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The runner is not in privileged mode.&lt;/strong&gt; &lt;code&gt;docker:dind&lt;/code&gt; runs its own daemon and needs &lt;code&gt;privileged = true&lt;/code&gt; in the runner &lt;code&gt;config.toml&lt;/code&gt;. Without it the container cannot start the daemon and dies during boot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The image failed to pull.&lt;/strong&gt; A registry rate limit or a typo in the tag means the service container never exists. Check the job log's &lt;code&gt;Preparing the "docker" ...&lt;/code&gt; service lines near the top.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pin the runner config and confirm privileged mode:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A3&lt;/span&gt; &lt;span class="s1"&gt;'\[runners.docker\]'&lt;/span&gt; /etc/gitlab-runner/config.toml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want to see &lt;code&gt;privileged = true&lt;/code&gt;. If it says &lt;code&gt;false&lt;/code&gt; or is absent, that is your problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A renamed service image broke the derived alias
&lt;/h3&gt;

&lt;p&gt;GitLab derives a service's default alias from its image name. &lt;code&gt;docker:28.3-dind&lt;/code&gt; becomes the alias &lt;code&gt;docker&lt;/code&gt;. But if you pull the image through a mirror or a private registry, the derived alias changes and &lt;code&gt;docker&lt;/code&gt; stops resolving:&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# WRONG: alias becomes "my-mirror.example.com__docker", not "docker"&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;my-mirror.example.com/library/docker:28.3-dind&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The daemon is running, but under a name your &lt;code&gt;DOCKER_HOST&lt;/code&gt; never asks for. Always set the alias explicitly when the image path is anything other than the bare &lt;code&gt;docker&lt;/code&gt; name:&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;services&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;my-mirror.example.com/library/docker:28.3-dind&lt;/span&gt;
    &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The Kubernetes executor puts services on localhost
&lt;/h3&gt;

&lt;p&gt;On the &lt;code&gt;docker&lt;/code&gt; executor, the service is a linked container with its own alias, so &lt;code&gt;tcp://docker:2376&lt;/code&gt; is correct. The Kubernetes executor is different: every service runs as a container in the same Pod as the build, sharing one network namespace. They all reach each other on &lt;code&gt;localhost&lt;/code&gt;, and the &lt;code&gt;docker&lt;/code&gt; alias may not resolve at all. If you copied a working &lt;code&gt;docker&lt;/code&gt;-executor config onto a Kubernetes runner, this is why it broke.&lt;/p&gt;

&lt;p&gt;Set the host to &lt;code&gt;localhost&lt;/code&gt; for the Kubernetes executor:&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://localhost:2376&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_CERT_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs/client"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_VERIFY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behavior here has shifted across runner versions, so verify against your own version rather than trusting a blog snippet. If &lt;code&gt;tcp://localhost:2376&lt;/code&gt; gives you &lt;code&gt;connection refused&lt;/code&gt; instead of &lt;code&gt;no such host&lt;/code&gt;, the name resolved and you are back to a daemon-startup problem (see cause 1). The two messages tell you which side of the wall you are on.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist that isolates the cause in under a minute
&lt;/h2&gt;

&lt;p&gt;Work through these in order the next time the job goes red:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the address in the error.&lt;/strong&gt; &lt;code&gt;no such host&lt;/code&gt; is DNS. &lt;code&gt;connection refused&lt;/code&gt; is a live-but-unreachable daemon. Do not fix the wrong one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm the &lt;code&gt;-dind&lt;/code&gt; tag.&lt;/strong&gt; Grep your &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; for the service image. No &lt;code&gt;-dind&lt;/code&gt;, no daemon, no alias.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check privileged mode&lt;/strong&gt; in the runner &lt;code&gt;config.toml&lt;/code&gt; as shown above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match &lt;code&gt;DOCKER_HOST&lt;/code&gt; to your executor.&lt;/strong&gt; &lt;code&gt;tcp://docker:2376&lt;/code&gt; for the &lt;code&gt;docker&lt;/code&gt; executor, &lt;code&gt;tcp://localhost:2376&lt;/code&gt; for Kubernetes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the port to TLS.&lt;/strong&gt; With &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; set, the daemon listens on &lt;code&gt;2376&lt;/code&gt;. Blank it out and it listens on the plain &lt;code&gt;2375&lt;/code&gt;. A mismatch here surfaces as a connection error once the name resolves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan the top of the job log&lt;/strong&gt; for the service &lt;code&gt;Preparing&lt;/code&gt;/&lt;code&gt;Waiting&lt;/code&gt; lines. A service that logs an exit code never registered its alias.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Confirm the daemon is reachable before you build
&lt;/h2&gt;

&lt;p&gt;Once the service is up and named correctly, prove the client can talk to it before your real build steps run. A one-line &lt;code&gt;docker info&lt;/code&gt; at the start of &lt;code&gt;script&lt;/code&gt; fails fast and loud instead of letting a ten-minute build collapse at the push step:&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;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker info&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t my-app .&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker push my-app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The daemon's TCP listener and its 2375-versus-2376 split are documented in the &lt;a href="https://docs.docker.com/reference/cli/dockerd/" rel="noopener noreferrer"&gt;Docker daemon reference&lt;/a&gt;; the TLS port only exists because &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; generated certificates on boot. Keep the port and the TLS variables in sync and the &lt;code&gt;no such host&lt;/code&gt; error stays gone. Once your images build cleanly, tightening them is the next job, and &lt;a href="https://dev.to/blog/docker-multi-stage-builds-smaller-secure-production-images"&gt;multi-stage builds&lt;/a&gt; are where that starts.&lt;/p&gt;

</description>
      <category>gitlabcidialtcplookupdocker</category>
      <category>dockerdindservicealias</category>
      <category>gitlabcidockerdnsfailure</category>
      <category>dockerhost</category>
    </item>
    <item>
      <title>GitLab CI "Cannot connect to unix:///var/run/docker.sock"</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:39:01 +0000</pubDate>
      <link>https://dev.to/devopsstart/gitlab-ci-cannot-connect-to-unixvarrundockersock-1doj</link>
      <guid>https://dev.to/devopsstart/gitlab-ci-cannot-connect-to-unixvarrundockersock-1doj</guid>
      <description>&lt;h2&gt;
  
  
  The fast fix
&lt;/h2&gt;

&lt;p&gt;If your GitLab CI job fails with &lt;code&gt;Cannot connect to the Docker daemon at unix:///var/run/docker.sock&lt;/code&gt;, your &lt;code&gt;docker&lt;/code&gt; client is looking for a local socket that does not exist inside the job container, because &lt;code&gt;DOCKER_HOST&lt;/code&gt; is not set. Point the client at the &lt;code&gt;docker:dind&lt;/code&gt; service over TCP and the error goes away:&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;build&lt;/span&gt;&lt;span class="pi"&gt;:&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;docker:28.3&lt;/span&gt;
  &lt;span class="na"&gt;services&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;docker:28.3-dind&lt;/span&gt;
      &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker&lt;/span&gt;
  &lt;span class="na"&gt;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2376&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs"&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_CERT_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs/client"&lt;/span&gt;
    &lt;span class="na"&gt;DOCKER_TLS_VERIFY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker info&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;docker build -t my-app .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole fix for the common case. The rest of this page explains why the socket variant of the error is different from the &lt;code&gt;tcp://docker:2375&lt;/code&gt; variant, and covers the two other setups (socket-mounted runners and the Kubernetes executor) where the same message shows up for a different reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you get the unix socket variant specifically
&lt;/h2&gt;

&lt;p&gt;This error is not the same as &lt;code&gt;Cannot connect to the Docker daemon at tcp://docker:2375&lt;/code&gt;. The address in the message tells you exactly what the client tried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;DOCKER_HOST&lt;/code&gt; is empty, the Docker CLI falls back to its compiled-in default, the local unix socket at &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. Inside a GitLab CI job that uses the &lt;code&gt;docker&lt;/code&gt; executor, that socket file simply is not there. The daemon runs in a separate &lt;code&gt;docker:dind&lt;/code&gt; service container, not in your job container, so there is nothing listening on the local socket. The client connects, finds no socket, and prints the message above.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;tcp://docker:2375&lt;/code&gt; form is the opposite problem: &lt;code&gt;DOCKER_HOST&lt;/code&gt; is set correctly but the dind service is not reachable (missing service, no privileged mode, or a TLS mismatch). If you are seeing that address instead, read the companion write-up on the &lt;a href="https://dev.to/troubleshooting/fix-gitlab-ci-docker-daemon-connection-error-in-3-steps"&gt;tcp://docker:2375 form of this error&lt;/a&gt;, which walks the service and privileged-mode causes in detail. This page is about the case where the client never even tried the network, because nothing told it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three real causes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. DOCKER_HOST is unset
&lt;/h3&gt;

&lt;p&gt;This is the usual cause. You added &lt;code&gt;services: - docker:dind&lt;/code&gt; but never set &lt;code&gt;DOCKER_HOST&lt;/code&gt;, so the client ignores the service container and hits the local socket. Set it as a job or top-level variable:&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2376&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use port &lt;code&gt;2376&lt;/code&gt; (TLS) when &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; is set, and &lt;code&gt;2375&lt;/code&gt; (plain) when you disable TLS with &lt;code&gt;DOCKER_TLS_CERTDIR: ""&lt;/code&gt;. Mixing them is the second most common mistake, covered next.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. TLS is half-configured
&lt;/h3&gt;

&lt;p&gt;Docker Engine 19.03 and later turns on TLS between the client and the daemon by default. The dind service generates certificates into the path named by &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt;. If you set the certs directory but point &lt;code&gt;DOCKER_HOST&lt;/code&gt; at the plain-text port &lt;code&gt;2375&lt;/code&gt;, or you set neither cert variable, the handshake fails and the client can end up falling back to the socket. Keep the three TLS variables consistent:&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2376&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_CERT_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/certs/client"&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_VERIFY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather skip TLS for an internal runner, disable it cleanly and use port &lt;code&gt;2375&lt;/code&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;variables&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tcp://docker:2375&lt;/span&gt;
  &lt;span class="na"&gt;DOCKER_TLS_CERTDIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick one style and set every variable it needs. Do not leave &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; set while talking to &lt;code&gt;2375&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The runner cannot start dind at all
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;DOCKER_HOST&lt;/code&gt; is right but the dind container never boots, the client still fails, sometimes reporting the socket address after a retry. The &lt;code&gt;docker:dind&lt;/code&gt; service needs privileged mode in the runner's &lt;code&gt;config.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[[runners]]
  executor = "docker"
  [runners.docker]
    privileged = true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;privileged = false&lt;/code&gt; or the key absent, dind cannot create its own daemon and no address will work. Confirm this on the runner host before touching your pipeline file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Socket-mounted runners are the exception
&lt;/h2&gt;

&lt;p&gt;Some self-managed runners deliberately mount the host's Docker socket instead of running dind. In that setup &lt;code&gt;/var/run/docker.sock&lt;/code&gt; is supposed to exist inside the job, and the same error means the mount is missing or the path is wrong. The runner's &lt;code&gt;config.toml&lt;/code&gt; binds the host socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[[runners]]
  executor = "docker"
  [runners.docker]
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this style you do not set &lt;code&gt;DOCKER_HOST&lt;/code&gt; at all, because the local socket is the daemon. If you get the unix socket error here, check that the host actually has Docker running and that the bind path in &lt;code&gt;volumes&lt;/code&gt; matches the real socket location. This approach shares the host daemon with every job, so treat it as a security tradeoff, not a default. The dind service is the safer choice for untrusted pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kubernetes executor needs the same variables
&lt;/h2&gt;

&lt;p&gt;Running GitLab Runner on Kubernetes does not change the fix, but it adds one gotcha. Each dind service runs as a sidecar container in the same Pod, so &lt;code&gt;DOCKER_HOST: tcp://docker:2376&lt;/code&gt; still resolves through the service alias. What breaks people is TLS cert sharing between containers in the Pod. Set an explicit shared volume for the certs directory in the runner's Helm values or pin &lt;code&gt;DOCKER_TLS_CERTDIR&lt;/code&gt; to a path both containers can read. If certs land in a directory only the dind container sees, the client falls back to the socket and you get this exact error inside Kubernetes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the fix
&lt;/h2&gt;

&lt;p&gt;Add a one-line probe to the top of your job and rerun the pipeline:&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="nv"&gt;$ &lt;/span&gt;docker info &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.ServerVersion}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints a version string, the client reached the daemon and your &lt;code&gt;docker build&lt;/code&gt; will work. If it still fails, echo the variable to confirm the pipeline actually applied 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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DOCKER_HOST=&lt;/span&gt;&lt;span class="nv"&gt;$DOCKER_HOST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty value here means your &lt;code&gt;variables&lt;/code&gt; block is scoped wrong (defined under the wrong job, or shadowed by a group or project variable). Move it to the top level or the specific job that runs Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Always set &lt;code&gt;DOCKER_HOST&lt;/code&gt; explicitly when you use &lt;code&gt;docker:dind&lt;/code&gt;. Never rely on the default socket in a CI job.&lt;/li&gt;
&lt;li&gt;Keep the TLS variables consistent: &lt;code&gt;2376&lt;/code&gt; with a cert dir, or &lt;code&gt;2375&lt;/code&gt; with &lt;code&gt;DOCKER_TLS_CERTDIR: ""&lt;/code&gt;. Never mix them.&lt;/li&gt;
&lt;li&gt;Pin the image and the dind service to the same tag, for example &lt;code&gt;docker:28.3&lt;/code&gt; and &lt;code&gt;docker:28.3-dind&lt;/code&gt;, so client and daemon versions match.&lt;/li&gt;
&lt;li&gt;Confirm &lt;code&gt;privileged = true&lt;/code&gt; in the runner &lt;code&gt;config.toml&lt;/code&gt; before pushing jobs that need dind.&lt;/li&gt;
&lt;li&gt;On Kubernetes, give the certs directory a shared volume so both containers in the Pod can read it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the daemon flags behind all of this, including the &lt;code&gt;-H&lt;/code&gt; host option and the default &lt;code&gt;unix:///var/run/docker.sock&lt;/code&gt; binding, see the &lt;a href="https://docs.docker.com/reference/cli/dockerd/" rel="noopener noreferrer"&gt;Docker daemon reference&lt;/a&gt;. Once your builds connect reliably, the &lt;a href="https://dev.to/blog/docker-multi-stage-builds-smaller-secure-production-images"&gt;multi-stage build guide&lt;/a&gt; is a good next step for shrinking the images those pipelines produce.&lt;/p&gt;

</description>
      <category>gitlabcidockerdaemon</category>
      <category>dockersockconnectionrefused</category>
      <category>gitlabcidockerdind</category>
      <category>dockerhost</category>
    </item>
    <item>
      <title>Fix Flux SOPS MAC mismatch in kustomize-controller</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Tue, 21 Jul 2026 09:41:48 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-flux-sops-mac-mismatch-in-kustomize-controller-4654</link>
      <guid>https://dev.to/devopsstart/fix-flux-sops-mac-mismatch-in-kustomize-controller-4654</guid>
      <description>&lt;p&gt;A SOPS &lt;code&gt;MAC mismatch&lt;/code&gt; in Flux almost always means one thing: the encrypted file was changed outside of sops. SOPS signs every file with a message authentication code computed over the plaintext at encrypt time. When &lt;code&gt;kustomize-controller&lt;/code&gt; decrypts the file and recomputes that code, it no longer matches, so Flux refuses the Secret and stops reconciling. You cannot patch the ciphertext by hand to fix this. The reliable fix is to recover the real values and re-encrypt the file from scratch, which is what the rest of this page walks through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the error looks like
&lt;/h2&gt;

&lt;p&gt;The failure shows up on the Kustomization, not the Secret. Check the object status and the controller logs:&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="nv"&gt;$ &lt;/span&gt;flux get kustomizations &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; flux-system logs deploy/kustomize-controller | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"mac mismatch"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will see a line similar to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kustomization/flux-system/apps: Reconciliation failed after 1.2s:
failed to decrypt secret 'db-credentials': Error getting data key:
Error decrypting tree: MAC mismatch. Expected '9f8c...', got 'a71b...'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two hashes are the point. "Expected" is the MAC that sops stored when the file was last encrypted correctly. "Got" is the MAC recomputed from the plaintext it just decrypted. They differ, so the content changed since the last clean encryption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;p&gt;Four situations produce a MAC mismatch in practice.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Fix path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File hand-edited outside sops&lt;/td&gt;
&lt;td&gt;Recent commit touched the &lt;code&gt;.enc.yaml&lt;/code&gt; directly&lt;/td&gt;
&lt;td&gt;Recover plaintext, re-encrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git merge conflict resolved by hand&lt;/td&gt;
&lt;td&gt;Merge commit on the encrypted file&lt;/td&gt;
&lt;td&gt;Re-merge from plaintext&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;encrypted_regex&lt;/code&gt; / &lt;code&gt;mac_only_encrypted&lt;/code&gt; changed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.sops.yaml&lt;/code&gt; edited since last encrypt&lt;/td&gt;
&lt;td&gt;Align rules, re-encrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Copy-paste truncation or corruption&lt;/td&gt;
&lt;td&gt;Value looks short or malformed&lt;/td&gt;
&lt;td&gt;Restore from git, re-encrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row covers most incidents. Opening an encrypted YAML in a text editor and changing a value, a key name, or even reindenting it alters the plaintext that sops will recompute the MAC over. The same goes for resolving a Git merge conflict by editing the encrypted file directly: you end up with a document sops never signed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;mac_only_encrypted&lt;/code&gt; case is subtler. When your &lt;code&gt;.sops.yaml&lt;/code&gt; sets &lt;code&gt;mac_only_encrypted: true&lt;/code&gt;, only the encrypted values feed the MAC. Flip that flag, or change which fields &lt;code&gt;encrypted_regex&lt;/code&gt; selects, and the recomputed MAC covers a different set of values than the stored one, even though nothing looks wrong in the diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it step by step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Confirm the file is the problem, not the key
&lt;/h3&gt;

&lt;p&gt;Decrypt the file locally with the same age key Flux uses. If you get a MAC mismatch here too, the file is corrupt and the cluster key secret is fine:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SOPS_AGE_KEY_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.config/sops/age/keys.txt
&lt;span class="nv"&gt;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; clusters/prod/db-credentials.enc.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If instead you see &lt;code&gt;no key could decrypt&lt;/code&gt; or a base64 error, that is a different failure. The related &lt;a href="https://dev.to/troubleshooting/fix-flux-sops-illegal-base64-data-at-input-byte-0"&gt;Flux SOPS illegal base64 error fix&lt;/a&gt; covers the key-encoding case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Recover the real plaintext
&lt;/h3&gt;

&lt;p&gt;You need a trustworthy copy of the values. Pick whichever source you actually trust.&lt;/p&gt;

&lt;p&gt;Restore the last known-good version from Git history:&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="nv"&gt;$ &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; clusters/prod/db-credentials.enc.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;git show &amp;lt;good-sha&amp;gt;:clusters/prod/db-credentials.enc.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/recovered.enc.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; /tmp/recovered.enc.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/plain.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you trust the current values but only the MAC is stale (for example the file was reindented, not semantically changed), decrypt while skipping MAC verification:&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="nv"&gt;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; &lt;span class="nt"&gt;--ignore-mac&lt;/span&gt; clusters/prod/db-credentials.enc.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/plain.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: &lt;code&gt;--ignore-mac&lt;/code&gt; does not work with &lt;code&gt;--in-place&lt;/code&gt;, and it will not rescue a file whose ciphertext was actually modified. Use it only to pull known-good plaintext back out so you can re-encrypt it.&lt;/p&gt;

&lt;p&gt;As a last resort, if the Secret already applied to the cluster at least once, read the live values back:&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="nv"&gt;$ &lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; app get secret db-credentials &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;'{.data.password}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Re-encrypt from clean plaintext
&lt;/h3&gt;

&lt;p&gt;Encrypt the recovered plaintext into a fresh file with a valid MAC:&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="nv"&gt;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--encrypt&lt;/span&gt; &lt;span class="nt"&gt;--age&lt;/span&gt; age1yourclusterpublickey /tmp/plain.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; clusters/prod/db-credentials.enc.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/plain.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you keep a &lt;code&gt;.sops.yaml&lt;/code&gt; with &lt;code&gt;creation_rules&lt;/code&gt; (recommended, so everyone encrypts identically), sops picks up the key and regex automatically:&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="nv"&gt;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--encrypt&lt;/span&gt; /tmp/plain.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; clusters/prod/db-credentials.enc.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Verify before you push
&lt;/h3&gt;

&lt;p&gt;Never push an encrypted file you have not decrypted at least once. This one command is the difference between a clean reconcile and another red Kustomization:&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="nv"&gt;$ &lt;/span&gt;sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; clusters/prod/db-credentials.enc.yaml | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns plaintext with no MAC error, the file is good.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Commit and reconcile
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git add clusters/prod/db-credentials.enc.yaml
&lt;span class="nv"&gt;$ &lt;/span&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: re-encrypt db-credentials to repair sops mac"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git push
&lt;span class="nv"&gt;$ &lt;/span&gt;flux reconcile kustomization apps &lt;span class="nt"&gt;--with-source&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the Kustomization go &lt;code&gt;Ready&lt;/code&gt; again with &lt;code&gt;flux get kustomizations&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can you tell Flux to ignore the MAC?
&lt;/h2&gt;

&lt;p&gt;No. The &lt;code&gt;kustomize-controller&lt;/code&gt; always verifies the MAC during decryption and exposes no ignore-mac option, by design: a Secret that fails integrity checks should not be applied to a cluster. Tested with Flux v2.4.0 and sops v3.9.4, there is no &lt;code&gt;spec.decryption&lt;/code&gt; field that disables MAC verification. The only durable fix is a file that decrypts cleanly, so treat the mismatch as a signal to re-encrypt rather than something to bypass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Never open a &lt;code&gt;.enc.yaml&lt;/code&gt; in a plain text editor. Run &lt;code&gt;sops clusters/prod/db-credentials.enc.yaml&lt;/code&gt;, which decrypts into your editor and recomputes the MAC on save.&lt;/li&gt;
&lt;li&gt;Add a CI check or pre-commit hook that runs &lt;code&gt;sops --decrypt&lt;/code&gt; on every changed encrypted file, so a broken MAC fails the pull request instead of the cluster.&lt;/li&gt;
&lt;li&gt;Commit a &lt;code&gt;.sops.yaml&lt;/code&gt; with &lt;code&gt;creation_rules&lt;/code&gt; so &lt;code&gt;encrypted_regex&lt;/code&gt; and key groups are identical for everyone. Inconsistent rules are a quiet source of MAC drift.&lt;/li&gt;
&lt;li&gt;Resolve merge conflicts on encrypted files by decrypting both sides, merging the plaintext, and re-encrypting. Do not hand-edit ciphertext to settle a conflict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the canonical setup, the &lt;a href="https://fluxcd.io/flux/guides/mozilla-sops/" rel="noopener noreferrer"&gt;official Flux SOPS guide&lt;/a&gt; documents the age and key-secret wiring end to end. If you are still deciding how to structure encrypted secrets across environments, the &lt;a href="https://dev.to/blog/argo-cd-vs-flux-a-guide-for-multi-cluster-gitops"&gt;Argo CD vs Flux multi-cluster GitOps guide&lt;/a&gt; and the &lt;a href="https://dev.to/tutorials/how-to-set-up-argo-cd-gitops-for-kubernetes-automation"&gt;Argo CD GitOps setup tutorial&lt;/a&gt; walk through the surrounding reconciliation model that this Secret plugs into.&lt;/p&gt;

</description>
      <category>fluxsops</category>
      <category>sopsmacmismatch</category>
      <category>kustomizecontroller</category>
      <category>gitopssecrets</category>
    </item>
    <item>
      <title>Fix vLLM CUDA OutOfMemoryError in Kubernetes</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sun, 19 Jul 2026 09:18:51 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-vllm-cuda-outofmemoryerror-in-kubernetes-3cij</link>
      <guid>https://dev.to/devopsstart/fix-vllm-cuda-outofmemoryerror-in-kubernetes-3cij</guid>
      <description>&lt;p&gt;If your vLLM pod dies at startup with &lt;code&gt;torch.cuda.OutOfMemoryError: CUDA out of memory&lt;/code&gt;, the model plus its KV cache needs more VRAM than the GPU allocated to that pod can give. The fastest fix is to cap two things: pass &lt;code&gt;--gpu-memory-utilization 0.85&lt;/code&gt; and &lt;code&gt;--max-model-len 4096&lt;/code&gt; on the serve command, then redeploy. If you have more than one GPU in the pod, add &lt;code&gt;--tensor-parallel-size N&lt;/code&gt; to shard the weights across them. Those three flags resolve most of these crashes. The rest of this guide explains when each one matters and the Kubernetes-specific traps that make the error worse than it looks.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is a GPU error, not a pod OOMKill
&lt;/h2&gt;

&lt;p&gt;The first thing to get straight: &lt;code&gt;torch.cuda.OutOfMemoryError&lt;/code&gt; is not the same failure as an &lt;code&gt;OOMKilled&lt;/code&gt; pod. An OOMKill happens when your container exceeds its host RAM limit and the kernel sends Exit Code 137. A CUDA OOM happens entirely inside the GPU's own memory, which the Linux OOM killer and your pod memory limit know nothing about. You can have gigabytes of free node RAM and still hit this. If you are chasing an Exit Code 137 instead, the diagnosis path is different and covered in &lt;a href="https://dev.to/troubleshooting/how-to-debug-oomkilled-pods-in-kubernetes-a-step-by-step-gui"&gt;debugging OOMKilled pods&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The full error usually looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 2.00 GiB.
GPU 0 has a total capacity of 39.38 GiB of which 224.00 MiB is free.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;vLLM does a memory profiling run at startup. It loads the weights, runs a forward pass to measure peak activation memory, then claims the remaining GPU memory (up to &lt;code&gt;gpu_memory_utilization&lt;/code&gt;) as a static KV cache pool. The crash happens when weights plus activations already exceed what is free, or when the KV cache target overcommits memory that another process on the GPU is holding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm what is actually on the GPU
&lt;/h2&gt;

&lt;p&gt;Before changing flags, look at the GPU from inside the running or crash-looping pod. Guessing wastes redeploys.&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="nv"&gt;$ &lt;/span&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; deploy/vllm-server &lt;span class="nt"&gt;--&lt;/span&gt; nvidia-smi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read two numbers from the output: total GPU memory and current used memory. If used memory is already high before vLLM starts, something else is sharing the card. That is common on shared or MIG-partitioned GPUs, where &lt;code&gt;gpu_memory_utilization&lt;/code&gt; of 0.9 (the vLLM default) is a fraction of the full physical card, not of your slice, so the target quietly overcommits.&lt;/p&gt;

&lt;p&gt;Then confirm how many GPUs the pod was actually granted:&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="nv"&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;vllm &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].resources.limits}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this shows &lt;code&gt;nvidia.com/gpu: "1"&lt;/code&gt; but you set &lt;code&gt;--tensor-parallel-size 2&lt;/code&gt;, vLLM will try to place shards on GPUs that were never scheduled to the pod, and you get an OOM (or a hang) instead of a clean error. GPUs reach the pod through the &lt;a href="https://kubernetes.io/docs/tasks/manage-gpus/scheduling-gpus/" rel="noopener noreferrer"&gt;Kubernetes device plugin&lt;/a&gt;, so the limit you request is the hard ceiling vLLM sees. The tensor parallel size must equal the GPU count in the resource limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four flags that fix it
&lt;/h2&gt;

&lt;p&gt;Each flag trades a different resource. Reach for them in this order.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--gpu-memory-utilization&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;Fraction of GPU memory vLLM may claim for weights plus KV cache. Lower it to leave headroom on a shared card.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--max-model-len&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;model max&lt;/td&gt;
&lt;td&gt;Caps context length. KV cache size scales with this, so a 128k model capped at 8k frees a large block.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--max-num-seqs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;Max sequences batched at once. Fewer concurrent requests means a smaller KV cache.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--tensor-parallel-size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Shards model weights across N GPUs in the pod. The main lever for models too big for one card.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start by trimming the KV cache, since that is where most waste lives:&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="nv"&gt;$ &lt;/span&gt;vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.85 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 4096 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the model weights alone do not fit on one GPU, no amount of KV cache trimming helps. That is when you shard:&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="nv"&gt;$ &lt;/span&gt;vllm serve meta-llama/Llama-3.1-70B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.90 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 8192
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rough sizing check: a model in FP16 needs about 2 GB of VRAM per billion parameters just for weights, before any KV cache. A 70B model is roughly 140 GB, so it will not fit on a single 80 GB A100 no matter how you tune the cache. Shard it across GPUs or quantize it with &lt;code&gt;--quantization fp8&lt;/code&gt; to halve the weight footprint. If you are still deciding which serving engine to run, the tradeoffs are compared in &lt;a href="https://dev.to/blog/choosing-an-llm-serving-engine-vllm-vs-tgi"&gt;choosing an LLM serving engine&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kubernetes-specific traps
&lt;/h2&gt;

&lt;p&gt;The same flags behave differently under Kubernetes than on a bare workstation. Three traps account for most repeat incidents.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Shared memory is too small for tensor parallelism
&lt;/h3&gt;

&lt;p&gt;vLLM uses shared memory for inter-GPU communication when &lt;code&gt;--tensor-parallel-size&lt;/code&gt; is greater than 1. Containers default &lt;code&gt;/dev/shm&lt;/code&gt; to 64 MB, which is far too small, and the symptom is often a confusing OOM or NCCL hang rather than a clear message. Mount a memory-backed volume:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;vllm-server&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;vllm&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;vllm/vllm-openai:latest&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;nvidia.com/gpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4"&lt;/span&gt;
        &lt;span class="na"&gt;volumeMounts&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;dshm&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/dev/shm&lt;/span&gt;
      &lt;span class="na"&gt;volumes&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;dshm&lt;/span&gt;
        &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;medium&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Memory&lt;/span&gt;
          &lt;span class="na"&gt;sizeLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;8Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Memory fragmentation on long-running pods
&lt;/h3&gt;

&lt;p&gt;If the error says a large amount is reserved but unallocated, the allocator has fragmented the pool. Set the PyTorch allocator to use expandable segments so freed blocks can be reused:&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;env&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;PYTORCH_CUDA_ALLOC_CONF&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expandable_segments:True"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. CUDA graph capture spikes memory
&lt;/h3&gt;

&lt;p&gt;vLLM captures CUDA graphs at startup for lower latency, and the capture itself needs extra memory. If the OOM lands during capture rather than during the profiling run, disable it with &lt;code&gt;--enforce-eager&lt;/code&gt;. You lose some throughput but the pod starts:&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="nv"&gt;$ &lt;/span&gt;vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.85 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--enforce-eager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Verify the fix
&lt;/h2&gt;

&lt;p&gt;After redeploying, watch the startup logs for the KV cache report. A healthy start prints the number of GPU blocks it allocated:&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="nv"&gt;$ &lt;/span&gt;kubectl logs &lt;span class="nt"&gt;-f&lt;/span&gt; deploy/vllm-server | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"kv cache"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that line appears and the readiness probe passes, the crash is resolved. Then send a real request so a full sequence actually fills the cache, since a crash can still surface under load rather than at boot:&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="nv"&gt;$ &lt;/span&gt;kubectl port-forward deploy/vllm-server 8000:8000 &amp;amp;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl http://localhost:8000/v1/completions &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model": "meta-llama/Llama-3.1-8B-Instruct", "prompt": "ping", "max_tokens": 16}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once serving is stable, keep an eye on GPU memory over time. A slow climb points to fragmentation or a KV cache sized too close to the limit, and wiring GPU metrics into your dashboards early makes that obvious before the next crash. See &lt;a href="https://dev.to/tutorials/llm-observability-on-kubernetes-a-practical-guide"&gt;LLM observability on Kubernetes&lt;/a&gt; for the metrics worth tracking, and the &lt;a href="https://dev.to/comparisons/top-llmops-tools-deploying-managing-llms-in-production"&gt;top LLMOps tools&lt;/a&gt; for the wider serving stack.&lt;/p&gt;

&lt;p&gt;The vLLM documentation on &lt;a href="https://docs.vllm.ai/en/latest/configuration/conserving_memory/" rel="noopener noreferrer"&gt;conserving memory&lt;/a&gt; lists the full set of flags and their interactions. For most Kubernetes deployments, though, the pattern is consistent: cap the KV cache first, shard the weights only when a single GPU genuinely cannot hold the model, and give tensor parallelism the shared memory it needs.&lt;/p&gt;

</description>
      <category>vllm</category>
      <category>llmops</category>
      <category>kubernetes</category>
      <category>gpumemory</category>
    </item>
    <item>
      <title>Fix "Resource not accessible by integration" in GitHub Actions</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Sat, 18 Jul 2026 09:00:34 +0000</pubDate>
      <link>https://dev.to/devopsstart/fix-resource-not-accessible-by-integration-in-github-actions-5c24</link>
      <guid>https://dev.to/devopsstart/fix-resource-not-accessible-by-integration-in-github-actions-5c24</guid>
      <description>&lt;p&gt;Your workflow logs a red &lt;code&gt;Error: Resource not accessible by integration&lt;/code&gt; and the job dies the moment it tries to write something back: a label, a comment, a commit, a release. The cause is almost always the same. The &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; your job runs with is read-only, so any API call that mutates the repository gets a 403. The fix is to grant that token the specific scope the failing step needs, using the &lt;code&gt;permissions&lt;/code&gt; key in your workflow. There are three other situations where that fix alone is not enough, and knowing which one you are in saves you an hour of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the error actually means
&lt;/h2&gt;

&lt;p&gt;Every workflow run gets a short-lived &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, generated per job and revoked when the job finishes. It authenticates as a bot identity (&lt;code&gt;github-actions[bot]&lt;/code&gt;) against the GitHub API. When a step calls the API to change repository state and the token lacks the matching permission, the API answers &lt;code&gt;403 Resource not accessible by integration&lt;/code&gt;. The word "integration" is GitHub API language for the app behind the token, not a hint that some external integration is misconfigured.&lt;/p&gt;

&lt;p&gt;So the message is really saying: this token is not allowed to do that. Two things decide what it is allowed to do: the repository or organization default, and any &lt;code&gt;permissions&lt;/code&gt; block in your workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: the default token is read-only
&lt;/h2&gt;

&lt;p&gt;Since 2023, new repositories default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; to read-only. Many organizations also flip existing repos to read-only as a hardening step, which is the right call. You can confirm the setting under Settings, Actions, General, Workflow permissions. If it says "Read repository contents and packages permissions", the default token cannot write anything.&lt;/p&gt;

&lt;p&gt;Do not fix this at the repository level by switching the default back to read/write. That grants every workflow in the repo broad access it does not need. Instead, grant the scope in the one workflow that needs it:&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;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;write&lt;/span&gt;        &lt;span class="c1"&gt;# push commits or tags&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# comment on or label PRs&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;release&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;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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./scripts/tag-release.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One rule trips people up constantly. The moment you add a &lt;code&gt;permissions&lt;/code&gt; block, GitHub switches that scope from permissive defaults to explicit mode: every permission you do not list becomes &lt;code&gt;none&lt;/code&gt;. If your job also needs to read packages or write to the Checks API, you have to list those too. A job that reads and writes typically needs a handful of scopes spelled out, not one.&lt;/p&gt;

&lt;p&gt;You can also scope permissions per job, which is stricter and what I reach for by default. A build job gets &lt;code&gt;contents: read&lt;/code&gt;, and only the publish job gets &lt;code&gt;contents: write&lt;/code&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&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;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;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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make build&lt;/span&gt;

  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&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;write&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;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;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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;make publish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full list of scopes and their defaults lives in the &lt;a href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication" rel="noopener noreferrer"&gt;GitHub Docs on automatic token authentication&lt;/a&gt;. Keep least privilege in mind here; over-scoping the token is one of the quiet ways CI becomes a security liability, a theme covered in &lt;a href="https://dev.to/blog/github-actions-security-how-to-stop-secret-leaks-in-cicd"&gt;GitHub Actions Security: How to Stop Secret Leaks in CI/CD&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: the pull request came from a fork
&lt;/h2&gt;

&lt;p&gt;This one catches teams with public repos. When a &lt;code&gt;pull_request&lt;/code&gt; event fires from a forked repository, GitHub deliberately hands the workflow a read-only &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and withholds secrets, no matter what your &lt;code&gt;permissions&lt;/code&gt; block says. An attacker could otherwise open a PR that runs arbitrary code with write access to your repo. The read-only downgrade is a security boundary, not a bug, and you cannot override it with the &lt;code&gt;permissions&lt;/code&gt; key.&lt;/p&gt;

&lt;p&gt;If you need to write back on a fork PR (post a comment, apply a label), use the &lt;code&gt;pull_request_target&lt;/code&gt; event instead. It runs in the context of the base repository, so the token can be granted write scopes:&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request_target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;]&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;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handle &lt;code&gt;pull_request_target&lt;/code&gt; carefully. It runs with repository secrets available, so never check out and execute untrusted PR code inside it. Check out the base branch, or only run trusted logic like labeling. The safer pattern for anything that needs the PR's build output is a two-workflow split: an untrusted &lt;code&gt;pull_request&lt;/code&gt; job that builds and uploads an artifact, and a trusted &lt;code&gt;workflow_run&lt;/code&gt; job that downloads it and writes results back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: the PR was opened by Dependabot
&lt;/h2&gt;

&lt;p&gt;Dependabot PRs look like internal PRs, but since March 2021 GitHub treats workflow runs triggered by Dependabot as if they came from a fork. The &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is read-only and repository secrets are unavailable. Since October 2021 those runs do respect the &lt;code&gt;permissions&lt;/code&gt; key, so a workflow that labels or auto-merges Dependabot PRs can work, but you still have to know two things.&lt;/p&gt;

&lt;p&gt;First, secrets your job expects are missing on a Dependabot event. Reference &lt;code&gt;secrets.DEPENDABOT_*&lt;/code&gt; values from the separate Dependabot secrets store, not the Actions secrets store. Second, the token is still fork-grade, so write operations that GitHub blocks for forks stay blocked. GitHub documents the exact event matrix in &lt;a href="https://docs.github.com/en/code-security/dependabot/troubleshooting-dependabot/troubleshooting-dependabot-on-github-actions" rel="noopener noreferrer"&gt;Troubleshooting Dependabot on GitHub Actions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A common working shape for auto-approving patch bumps:&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;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;write&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;automerge&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;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.actor == 'dependabot[bot]'&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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gh pr merge --auto --squash "$PR_URL"&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;PR_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.html_url }}&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cause 4: the action creates or approves a PR
&lt;/h2&gt;

&lt;p&gt;Some steps hit a separate switch. Actions that open a pull request or approve one (for example, &lt;code&gt;peter-evans/create-pull-request&lt;/code&gt;) need both &lt;code&gt;pull-requests: write&lt;/code&gt; on the token and a repository setting that is off by default. Under Settings, Actions, General, look for "Allow GitHub Actions to create and approve pull requests" and enable it. Without that box checked, the API returns the same &lt;code&gt;Resource not accessible by integration&lt;/code&gt; even when your &lt;code&gt;permissions&lt;/code&gt; block looks correct. This is the one case where the scope is right and the error still fires, so check it early if the token clearly has &lt;code&gt;pull-requests: write&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A diagnosis checklist
&lt;/h2&gt;

&lt;p&gt;Work through this in order. The first match is almost always your fix:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the failing step. Which API call 403'd (contents, issues, pull-requests, packages, deployments)? That names the scope you are missing.&lt;/li&gt;
&lt;li&gt;Is the trigger a fork &lt;code&gt;pull_request&lt;/code&gt; or a Dependabot PR? If yes, the token is read-only by design. Move the write logic to &lt;code&gt;pull_request_target&lt;/code&gt; or a &lt;code&gt;workflow_run&lt;/code&gt; job (fork) or accept fork-grade limits (Dependabot).&lt;/li&gt;
&lt;li&gt;Does the repo default to read-only? Add a &lt;code&gt;permissions&lt;/code&gt; block granting only the scope from step 1.&lt;/li&gt;
&lt;li&gt;Is the step creating or approving a PR? Enable the repository setting for it.&lt;/li&gt;
&lt;li&gt;Re-run and confirm.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To see what the token actually carries at runtime, print the scopes near the top of the failing job:&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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | gh auth login &lt;span class="nt"&gt;--with-token&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;gh api rate_limit &lt;span class="nt"&gt;-i&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'x-oauth-scopes'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"using GITHUB_TOKEN, scopes set by permissions block"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the workflow is a Terraform or infrastructure pipeline that comments plans back on PRs, the same permission model applies; a full working example lives in &lt;a href="https://dev.to/tutorials/how-to-automate-terraform-reviews-with-github-actions"&gt;How to Automate Terraform Reviews with GitHub Actions&lt;/a&gt;. And if you are moving a mutating workflow behind a gate before it touches production, the patterns in &lt;a href="https://dev.to/blog/testing-in-production-guide-to-progressive-delivery"&gt;Testing in Production: Guide to Progressive Delivery&lt;/a&gt; pair well with least-privilege tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the fix
&lt;/h2&gt;

&lt;p&gt;After adding the scope, re-run the job and confirm the failing API call now succeeds. A green run is the real signal, but you can also confirm intent by reading the run's permissions in the logs: expand the "Set up job" step, and GitHub prints the resolved &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; permissions for that job. If the scope you added shows there and the call still 403s, you are in Cause 2, 3, or 4, not a missing scope. That distinction is the whole game with this error: decide whether the token could carry the permission at all, then whether you actually granted it.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cicd</category>
      <category>githubtoken</category>
      <category>permissions</category>
    </item>
    <item>
      <title>k8s-aibom: Automated AI BOM for Kubernetes Workloads</title>
      <dc:creator>DevOps Start</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:13:43 +0000</pubDate>
      <link>https://dev.to/devopsstart/k8s-aibom-automated-ai-bom-for-kubernetes-workloads-5g3m</link>
      <guid>https://dev.to/devopsstart/k8s-aibom-automated-ai-bom-for-kubernetes-workloads-5g3m</guid>
      <description>&lt;p&gt;If you run a shared Kubernetes cluster, you already have AI workloads you don't know about. Someone shipped a vLLM inference service last sprint, a data team stood up a RAG pipeline behind a plain Deployment, and a contractor left an Ollama pod running in a namespace nobody audits. &lt;code&gt;k8s-aibom&lt;/code&gt;, the controller Google Cloud open-sourced this month, exists to find exactly those. It watches your live workloads and writes a CycloneDX 1.6 ML-BOM for every AI system it can identify, so the answer to "what AI is running in this cluster right now" stops being a guess.&lt;/p&gt;

&lt;p&gt;That "right now" is the whole point. A build-time SBOM tells you what your CI pipeline thought it was shipping. It says nothing about the pod a teammate &lt;code&gt;kubectl apply&lt;/code&gt;'d by hand at 2am, or the image that pulled a new model layer since the last scan. Shadow AI is a runtime problem, and &lt;code&gt;k8s-aibom&lt;/code&gt; is a runtime tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a runtime AI BOM is different
&lt;/h2&gt;

&lt;p&gt;An AI Bill of Materials answers the same question a software BOM does, scoped to AI: which models, frameworks, and inference engines are in play, and where did they come from. The category matters now because regulators started asking. The EU AI Act's logging and transparency obligations, the NIST AI Risk Management Framework's "know what you deployed" controls, and ISO/IEC 42001's inventory clauses all assume you can produce a current, accurate list of your AI systems. You cannot produce that list from a spreadsheet someone updates quarterly.&lt;/p&gt;

&lt;p&gt;Build-time and runtime BOMs solve different halves of the problem. Your build pipeline can attest to what it produced, and tools that live there are a good idea. But the pipeline never sees the workload that skipped it. If your supply-chain story stops at the CI system, read our take on why that is not enough in &lt;a href="https://dev.to/blog/supply-chain-security-proxy-move-beyond-vulnerability-scanni"&gt;Supply Chain Security Proxy: Move Beyond Vulnerability Scanning&lt;/a&gt;. &lt;code&gt;k8s-aibom&lt;/code&gt; fills the runtime gap: it reconciles against what the API server actually reports, not what a pipeline claims it built.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the controller actually watches
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;k8s-aibom&lt;/code&gt; is a standard Kubernetes controller, not a DaemonSet or a privileged agent. It reconciles a set of workload kinds and emits a BOM per workload. The kinds it tracks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deployments&lt;/li&gt;
&lt;li&gt;StatefulSets&lt;/li&gt;
&lt;li&gt;DaemonSets&lt;/li&gt;
&lt;li&gt;Jobs and CronJobs&lt;/li&gt;
&lt;li&gt;KServe &lt;code&gt;InferenceService&lt;/code&gt; resources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That list covers the shapes AI actually takes in a cluster. Inference services and agent stacks run as Deployments, batch training and evaluation runs as Jobs, and model servers packaged for KServe show up as &lt;code&gt;InferenceService&lt;/code&gt; objects. Because it reconciles against the API server, a workload created outside your GitOps flow is just as visible as one that went through it. That is the property that makes it useful against shadow AI: you did not have to know the workload existed for the controller to catalog it.&lt;/p&gt;

&lt;p&gt;Detection works by pattern-matching signals the workload already carries: container image references, command-line arguments, environment variables such as &lt;code&gt;HF_MODEL_ID&lt;/code&gt;, mounted volumes, and workload annotations. From those signals it recognizes a broad set of AI software:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Examples it identifies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inference runtimes&lt;/td&gt;
&lt;td&gt;vLLM, Hugging Face TGI, NVIDIA Triton, Ollama, Ray Serve, SGLang&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent frameworks&lt;/td&gt;
&lt;td&gt;LangChain, LangGraph, AutoGen, CrewAI, Langflow, Flowise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector databases&lt;/td&gt;
&lt;td&gt;Milvus, Qdrant, Weaviate, Chroma, pgvector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training frameworks&lt;/td&gt;
&lt;td&gt;PyTorch, KubeRay, JAX, Hugging Face Accelerate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation tools&lt;/td&gt;
&lt;td&gt;lm-evaluation-harness, Ragas, Trulens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are already running two inference engines and cannot decide whether that is a problem, our comparison &lt;a href="https://dev.to/blog/choosing-an-llm-serving-engine-vllm-vs-tgi"&gt;Choosing an LLM Serving Engine: vLLM vs TGI&lt;/a&gt; covers the tradeoffs &lt;code&gt;k8s-aibom&lt;/code&gt; will happily inventory for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What detection looks like on a real workload
&lt;/h2&gt;

&lt;p&gt;Concretely, picture a RAG API someone shipped as a plain Deployment. Nothing about the object name says "AI", but the pod spec gives it away:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;rag-api&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;team-ml&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;server&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;vllm/vllm-openai:v0.6.3&lt;/span&gt;
          &lt;span class="na"&gt;args&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;--model"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta-llama/Llama-3.1-8B-Instruct"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
          &lt;span class="na"&gt;env&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;HF_MODEL_ID&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta-llama/Llama-3.1-8B-Instruct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller does not need a label saying this is AI. The &lt;code&gt;vllm/vllm-openai&lt;/code&gt; image matches its inference-runtime catalog, the &lt;code&gt;--model&lt;/code&gt; arg and &lt;code&gt;HF_MODEL_ID&lt;/code&gt; env var name the model, and both get recorded on the BOM. The image tag lands as &lt;code&gt;declared&lt;/code&gt; because it is read straight from the spec; the runtime identification lands as &lt;code&gt;inferred&lt;/code&gt; because it came from a heuristic. A workload that went out of its way to hide, say a custom image with the model passed through a mounted config file, would still surface the pieces the controller can see and mark the rest &lt;code&gt;unresolved&lt;/code&gt;. That gap is visible in the BOM, which is the honest behavior: you get told what the tool is unsure about instead of a confident lie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying it
&lt;/h2&gt;

&lt;p&gt;Deployment is a Helm install into its own namespace. You build and push the image, then install the chart:&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="nv"&gt;$ &lt;/span&gt;git clone https://github.com/GoogleCloudPlatform/k8s-aibom.git
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;k8s-aibom
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;IMG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-registry.example.com/k8s-aibom:v1.0.0
&lt;span class="nv"&gt;$ &lt;/span&gt;make image
&lt;span class="nv"&gt;$ &lt;/span&gt;make docker-push
&lt;span class="nv"&gt;$ &lt;/span&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;k8s-aibom ./charts/k8s-aibom &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--namespace&lt;/span&gt; k8s-aibom-system &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; image.repository&lt;span class="o"&gt;=&lt;/span&gt;my-registry.example.com/k8s-aibom &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; image.tag&lt;span class="o"&gt;=&lt;/span&gt;v1.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller does not scan every namespace by default, which is the right call on a busy cluster. You opt a namespace in with a label:&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="nv"&gt;$ &lt;/span&gt;kubectl label namespace team-ml aibom.k8saibom.dev/enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This opt-in model is deliberate. On a large platform you probably want to start with the namespaces where AI is likely, confirm the BOMs look right, then widen the net. Rolling it cluster-wide on day one buries you in output before you have tuned anything.&lt;/p&gt;

&lt;p&gt;The custom resources live under the &lt;code&gt;aibom.k8saibom.dev/v1alpha1&lt;/code&gt; API group. There are two kinds: &lt;code&gt;AIBOM&lt;/code&gt;, a namespace-scoped resource holding the BOM for one workload, and &lt;code&gt;AIBOMControllerConfig&lt;/code&gt;, a cluster-scoped singleton that configures where BOMs are sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a BOM
&lt;/h2&gt;

&lt;p&gt;Once a namespace is enabled, the controller starts producing &lt;code&gt;AIBOM&lt;/code&gt; resources. You read them like any other object:&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="nv"&gt;$ &lt;/span&gt;kubectl get aibom &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl describe aibom &lt;span class="nt"&gt;-n&lt;/span&gt; team-ml deployment-rag-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output is a CycloneDX 1.6 ML-BOM. If the document is small it lives inline in the resource status; if it is large the status carries a reference to the externalized copy instead, so you are not stuffing megabytes into etcd.&lt;/p&gt;

&lt;p&gt;The detail worth understanding is the confidence model. &lt;code&gt;k8s-aibom&lt;/code&gt; does not pretend every field is a hard fact. Each attribute is tagged as one of three states:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;declared&lt;/code&gt;: taken straight from the workload spec or an explicit annotation, so it is authoritative.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inferred&lt;/code&gt;: derived from a heuristic, such as recognizing an inference runtime from its image and args.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unresolved&lt;/code&gt;: the controller saw a signal but could not pin it down with confidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That grading is what makes the output auditable rather than a pile of guesses. When a reviewer asks why a BOM claims a workload runs a particular model, the answer is a field-level provenance tag, not a shrug. If you have been burned by AI tools that state everything with false certainty, our writeup &lt;a href="https://dev.to/blog/ai-agent-risks-lessons-from-snyks-10000-environment-audit"&gt;AI Agent Risks: Lessons from Snyk's 10,000 Environment Audit&lt;/a&gt; is a good reminder of why that provenance matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending BOMs somewhere durable
&lt;/h2&gt;

&lt;p&gt;A BOM that only lives in a cluster resource disappears when the workload does, which is useless for an audit trail. &lt;code&gt;AIBOMControllerConfig&lt;/code&gt; defines sinks that push each BOM out. Three sink types exist: the always-on CR status (no external egress), a Google Cloud Storage bucket, and a generic webhook. A config with both external sinks looks like this:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aibom.k8saibom.dev/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AIBOMControllerConfig&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;sinks&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;audit-archive&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GCS&lt;/span&gt;
      &lt;span class="na"&gt;gcs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-aibom-archive&lt;/span&gt;
        &lt;span class="na"&gt;pathTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aibom/{namespace}/{kind}-{name}/{timestamp}.json"&lt;/span&gt;
        &lt;span class="na"&gt;workloadIdentity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;k8s-aibom-controller@my-project.iam.gserviceaccount.com&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;graph-ingest&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Webhook&lt;/span&gt;
      &lt;span class="na"&gt;webhook&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://guac.internal.example.com/ingest&lt;/span&gt;
        &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;bearerToken&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;secretRef&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;graph-ingest-creds&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GCS sink has a property worth calling out: writes use a &lt;code&gt;DoesNotExist&lt;/code&gt; precondition, so a stored BOM cannot be overwritten once created. That turns the bucket into an append-only historical record. For anyone who has ever tried to reconstruct "what was running when the incident happened" from mutable logs, an immutable, timestamped BOM per workload is a real upgrade. Pair the &lt;code&gt;pathTemplate&lt;/code&gt; above with a bucket retention policy and you have a compliance artifact that survives the workload that produced it.&lt;/p&gt;

&lt;p&gt;The webhook sink is how you feed a graph database or an SBOM platform. A common pattern is pushing into a supply-chain graph so AI components sit alongside your other software inventory instead of in a separate silo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits, and where it doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;k8s-aibom&lt;/code&gt; is narrow on purpose, and that is a strength. It does not scan for vulnerabilities, enforce policy, or block anything. It builds an accurate inventory of AI workloads and gets it somewhere durable. Everything downstream, such as CVE correlation, policy gates, and drift alerts, is a separate tool consuming the BOM. Trying to make one controller do all of that is how you end up with a privileged agent nobody trusts.&lt;/p&gt;

&lt;p&gt;Keep three limitations in mind before you lean on it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detection is pattern-based, so it has a coverage frontier.&lt;/strong&gt; A homegrown inference server with no recognizable image, args, or environment signals may land as &lt;code&gt;unresolved&lt;/code&gt; or be missed. The &lt;code&gt;v1alpha1&lt;/code&gt; API group is a fair signal that the detection catalog is still moving. Treat the BOM as a strong lead, not a guarantee of completeness, and watch what shows up &lt;code&gt;unresolved&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It reports, it does not enforce.&lt;/strong&gt; Finding a shadow workload and doing something about it are different jobs. You still need policy tooling, whether that is an admission controller or a governance layer, to act on what the BOM reveals. If you are building that layer, &lt;a href="https://dev.to/blog/governing-ai-agents-in-cicd-with-opa-and-mcp"&gt;Governing AI Agents in CI/CD with OPA and MCP&lt;/a&gt; covers the policy side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The GCS sink is Google Cloud native.&lt;/strong&gt; The webhook sink is portable and works anywhere, but the tightest integration, immutable object writes via Workload Identity, assumes GKE. On other platforms you wire the webhook into your own durable store.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Shadow AI is not going away, and "we think we know what's running" is not an answer an auditor accepts. &lt;code&gt;k8s-aibom&lt;/code&gt; gives you a runtime, provenance-tagged inventory of the AI workloads actually live in your cluster, written to an immutable store you can hand to a compliance review. It is early software with a moving detection catalog, so verify its output rather than trusting it blindly. But as a way to turn shadow AI from an unknown into a tracked list, it is a genuinely useful addition to a Kubernetes security stack.&lt;/p&gt;

&lt;p&gt;Start small: install it, enable one namespace where you suspect unmanaged AI, and read the first few BOMs. The controller runs as a lightweight reconciler with no privileged access, so there is little downside to letting it watch. For the broader context on how Google frames this problem, their &lt;a href="https://cloud.google.com/security/solutions/software-supply-chain-security" rel="noopener noreferrer"&gt;software supply chain security&lt;/a&gt; guidance and the &lt;a href="https://cloud.google.com/blog/products/containers-kubernetes/how-gke-powers-ai-innovation" rel="noopener noreferrer"&gt;how GKE powers AI innovation&lt;/a&gt; writeup are worth reading. For the Kubernetes primitives the controller builds on, the upstream &lt;a href="https://kubernetes.io/docs/concepts/architecture/controller/" rel="noopener noreferrer"&gt;controllers documentation&lt;/a&gt; covers the reconcile loop it uses.&lt;/p&gt;

</description>
      <category>aisupplychainsecurity</category>
      <category>shadowai</category>
      <category>kubernetessecurity</category>
      <category>aibom</category>
    </item>
  </channel>
</rss>
