<?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 Daily</title>
    <description>The latest articles on DEV Community by DevOps Daily (@devopsdaily).</description>
    <link>https://dev.to/devopsdaily</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%2F382434%2F66e04ef9-7e4f-491c-997a-30f4a999d40c.jpg</url>
      <title>DEV Community: DevOps Daily</title>
      <link>https://dev.to/devopsdaily</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devopsdaily"/>
    <language>en</language>
    <item>
      <title>Node.js November 2026 Security Release: How We Tracked and Patched It</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 11 Nov 2026 17:50:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/nodejs-november-2026-security-release-how-we-tracked-and-patched-it-of</link>
      <guid>https://dev.to/devopsdaily/nodejs-november-2026-security-release-how-we-tracked-and-patched-it-of</guid>
      <description>&lt;p&gt;On 4 November 2026 the Node.js project posted a pre-alert: new releases of the 26.x, 24.x and 22.x lines "on or shortly after, Wednesday, November 11, 2026", and "The highest severity issue fixed in this release is HIGH". No CVE numbers, no components, nothing to triage against. You know a fix is coming, you know it matters, and you do not know when it lands or what it touches.&lt;/p&gt;

&lt;p&gt;That gap is where most patch delays come from. The release ships in the afternoon, nobody is watching, and the upgrade happens days later. This time we wrote a small watcher for the release, saw Node.js 24.22.3 appear at 16:58 UTC on release day, and had production on the new version with smoke tests passing 14 minutes later.&lt;/p&gt;

&lt;p&gt;This post covers what the release fixes and who should move first, the watcher script, the exact nvm, pm2 and systemd steps we used, and the checklist we will reuse for the next one.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixed versions:&lt;/strong&gt; Node.js &lt;strong&gt;22.23.5&lt;/strong&gt; , &lt;strong&gt;24.22.3&lt;/strong&gt; and &lt;strong&gt;26.13.1&lt;/strong&gt;. &lt;code&gt;https://nodejs.org/dist/index.json&lt;/code&gt; marks all three as security releases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What was fixed:&lt;/strong&gt; 16 CVEs: 7 high, 6 medium, 3 low. All seven high-severity ones are in HTTP/2 and TLS handling: remote crashes, server memory sent to the peer, and a client-certificate bypass. llhttp, undici, sqlite and brotli were updated too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move first:&lt;/strong&gt; any Node process that terminates TLS or serves HTTP/2 to untrusted clients. Everyone on 22, 24 or 26 should upgrade. End-of-life lines get no fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for it:&lt;/strong&gt; poll &lt;code&gt;dist/index.json&lt;/code&gt; every 15 minutes, or subscribe to the &lt;code&gt;nodejs-sec&lt;/code&gt; list and the vulnerability RSS feed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade order under nvm and pm2:&lt;/strong&gt; install, reinstall pm2, back up &lt;code&gt;dump.pm2&lt;/code&gt;, move apps one at a time with a health check, then move the pm2 daemon by restarting its systemd unit. Do not use &lt;code&gt;pm2 update&lt;/code&gt; for that last step.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A Linux host running Node.js apps installed with &lt;strong&gt;nvm&lt;/strong&gt; and supervised by &lt;strong&gt;pm2&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A systemd unit for the pm2 daemon (the one &lt;code&gt;pm2 startup&lt;/code&gt; generates)&lt;/li&gt;
&lt;li&gt;Shell access as the app user, plus root for the unit&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;python3&lt;/code&gt; for the watcher and the checks&lt;/li&gt;
&lt;li&gt;A health endpoint per app and a smoke test you trust&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The announcement and the release
&lt;/h2&gt;

&lt;p&gt;Node.js security releases follow a fixed pattern. About a week ahead, a pre-alert goes up at &lt;a href="https://nodejs.org/en/blog/vulnerability/november-2026-security-releases" rel="noopener noreferrer"&gt;nodejs.org/en/blog/vulnerability&lt;/a&gt; with the target date, the affected lines, and the highest severity. The details stay private until the fixed builds are out, so nobody can work out the bugs from the patch before you can install it.&lt;/p&gt;

&lt;p&gt;For this release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 November:&lt;/strong&gt; pre-alert published. 26.x, 24.x and 22.x, highest severity HIGH.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11 November, afternoon UTC:&lt;/strong&gt; the builds appeared in &lt;code&gt;dist/index.json&lt;/code&gt; with &lt;code&gt;"security": true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11 November, 17:11 UTC:&lt;/strong&gt; the advisory page was updated with the full CVE list (the time comes from the nodejs.org repository history).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the binaries were downloadable before the write-up existed. If you wait for the blog post, you start later than you need to.&lt;/p&gt;

&lt;p&gt;You can confirm the fixed versions yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s https://nodejs.org/dist/index.json &lt;span class="o"&gt;|&lt;/span&gt; python3 -c &lt;span class="s1"&gt;'
import sys, json
for r in json.load(sys.stdin):
    if r["version"] in ("v26.13.1", "v24.22.3", "v22.23.5"):
        print(r["version"], r["date"], "security:", r["security"])'&lt;/span&gt;


v26.13.1 2026-11-10 security: True
v24.22.3 2026-11-10 security: True
v22.23.5 2026-11-11 security: True

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;date&lt;/code&gt; field is the build date, which is why two of them show the day before release.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was fixed
&lt;/h2&gt;

&lt;p&gt;The advisory lists 16 CVEs. Severity and affected lines below are as the advisory states them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CVE&lt;/th&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;Lines&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;CVE-2026-123863&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;24, 26&lt;/td&gt;
&lt;td&gt;Any unauthenticated client can abort the server through an assertion in session teardown, over TLS or cleartext&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123864&lt;/td&gt;
&lt;td&gt;HTTPS&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;A zero-length TLS write re-enters the HTTP parser; memory from other connections, including TLS private keys, can be copied into a request body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123865&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;A protocol error during shutdown leaves the session behind; memory and file descriptors leak with no further cost to the attacker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123866&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;Stream teardown during a backpressured write sends freed memory to the peer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-126051&lt;/td&gt;
&lt;td&gt;TLS&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;24, 26&lt;/td&gt;
&lt;td&gt;Remote crash in TLS servers that use &lt;code&gt;SNICallback&lt;/code&gt;, &lt;code&gt;OCSPRequest&lt;/code&gt;, &lt;code&gt;newSession&lt;/code&gt;, &lt;code&gt;resumeSession&lt;/code&gt; or &lt;code&gt;server.addContext()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123872&lt;/td&gt;
&lt;td&gt;TLS&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;TLS 1.2 renegotiation can change the servername and carry client-certificate auth to a stricter host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123874&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;24, 26&lt;/td&gt;
&lt;td&gt;Use-after-free when a &lt;code&gt;respondWithFile()&lt;/code&gt; or &lt;code&gt;respondWithFD()&lt;/code&gt; stream is reset during a pending write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123828&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;24, 26&lt;/td&gt;
&lt;td&gt;One-byte heap overflow in &lt;code&gt;session.altsvc()&lt;/code&gt; with a long origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123867&lt;/td&gt;
&lt;td&gt;TLS (mTLS)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;A CA-only SNI context falls back to the default client CA, bypassing per-host mTLS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123868&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;24, 26&lt;/td&gt;
&lt;td&gt;Queued inbound DATA is not charged to &lt;code&gt;maxSessionMemory&lt;/code&gt;, so one stream can hold hundreds of megabytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123871&lt;/td&gt;
&lt;td&gt;HTTP proxy&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;Proxy credentials appear in &lt;code&gt;ERR_PROXY_INVALID_CONFIG&lt;/code&gt; error messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123877&lt;/td&gt;
&lt;td&gt;https.Agent&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;A TLS session from the default trust store can be reused by a request with a stricter &lt;code&gt;ca&lt;/code&gt; policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123875&lt;/td&gt;
&lt;td&gt;HTTP/2&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;Cancelling a &lt;code&gt;respondWithFile()&lt;/code&gt; download crashes the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123829&lt;/td&gt;
&lt;td&gt;Permission Model&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;Recursive &lt;code&gt;fs.watch&lt;/code&gt; leaks names of denied files (macOS and Windows)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-123862&lt;/td&gt;
&lt;td&gt;zlib&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;params()&lt;/code&gt; plus &lt;code&gt;destroy()&lt;/code&gt; throws an uncatchable assertion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CVE-2026-106071&lt;/td&gt;
&lt;td&gt;Permission Model&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;22, 24, 26&lt;/td&gt;
&lt;td&gt;A wildcard filesystem grant can match paths it should not&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The release notes also update &lt;strong&gt;llhttp&lt;/strong&gt; to 9.4.4 on all three lines, &lt;strong&gt;sqlite&lt;/strong&gt; to 3.54.1, &lt;strong&gt;undici&lt;/strong&gt; to 7.30.2 on 24.x and 8.12.4 on 26.x, and &lt;strong&gt;brotli&lt;/strong&gt; to 1.2.1 on 24.x and 26.x.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The advisory and the release notes do not match exactly on the low-severity Permission Model items. The release notes list CVE-2026-123873 ("permission: check heap snapshot destination") and do not mention CVE-2026-106071. If you run with &lt;code&gt;--permission&lt;/code&gt;, read both. The action is the same either way: upgrade.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Who should upgrade first
&lt;/h2&gt;

&lt;p&gt;Everyone on 22.x, 24.x or 26.x should upgrade. End-of-life lines such as 20.x and 25.x get no fixed release, and the advisory notes that end-of-life versions are always affected. Use the following order to decide what moves first.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Node terminates TLS or speaks HTTP/2 to the internet.&lt;/strong&gt; &lt;code&gt;https.createServer&lt;/code&gt;, &lt;code&gt;http2.createSecureServer&lt;/code&gt;, &lt;code&gt;http2.createServer&lt;/code&gt;, &lt;code&gt;tls.createServer&lt;/code&gt;, or a framework option that enables them. Most of the high-severity bugs sit here, and several need nothing more than a default server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mTLS with per-host policies.&lt;/strong&gt; CVE-2026-123872 and CVE-2026-123867 are authorization bypasses, not just crashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else.&lt;/strong&gt; If a reverse proxy terminates TLS and talks plain HTTP/1.1 to Node, the HTTP/2 and TLS server bugs are not reachable from clients. The client-side fixes (&lt;code&gt;https.Agent&lt;/code&gt; session reuse, proxy credentials in error messages, the undici update) still apply.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two quick checks tell you where you stand. First, which Node processes listen, and on which address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Run as root so ss can see every process&lt;/span&gt;
ss -ltnpH &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; -r _ _ _ addr _ users&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$users"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; grep -o &lt;span class="s1"&gt;'pid=[0-9]*'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; head -1 &lt;span class="o"&gt;|&lt;/span&gt; cut -d= -f2&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt;-n &lt;span class="s2"&gt;"$pid"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;continue&lt;/span&gt;
  &lt;span class="nv"&gt;exe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;readlink &lt;span class="s2"&gt;"/proc/$pid/exe"&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"$exe"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; */node&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$addr $exe"&lt;/span&gt; &lt;span class="o"&gt;;&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt; &lt;span class="c"&gt;# match the binary, not the process name&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything bound to &lt;code&gt;0.0.0.0&lt;/code&gt;, &lt;code&gt;[::]&lt;/code&gt; or &lt;code&gt;*&lt;/code&gt; is reachable from outside the host unless a firewall says otherwise. Second, which code paths you use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;grep -rnE &lt;span class="s1"&gt;'https\.createServer|http2\.create(Secure)?Server|tls\.createServer|respondWith(File|FD)|SNICallback|addContext|altsvc|flushHeaders'&lt;/span&gt; \
  --include=&lt;span class="s1"&gt;'*.js'&lt;/span&gt; --include=&lt;span class="s1"&gt;'*.mjs'&lt;/span&gt; --include=&lt;span class="s1"&gt;'*.cjs'&lt;/span&gt; --include=&lt;span class="s1"&gt;'*.ts'&lt;/span&gt; src/

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No hits does not make you safe from the client-side fixes, but it does tell you how urgent the rest is.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we watched for the release
&lt;/h2&gt;

&lt;p&gt;The pre-alert gives a date, not a time. We did not want someone refreshing a browser tab all day, and we did not want a cron entry to remember to delete afterwards. So we started one background script on release morning that polls &lt;code&gt;dist/index.json&lt;/code&gt; every 15 minutes, stops after about 16 hours, and exits as soon as a new 24.x shows up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# node-release-watch.sh: poll nodejs.org until a Node 24 release newer than&lt;/span&gt;
&lt;span class="c"&gt;# the current newest 24.x appears. Every 15 minutes, gives up after ~16 h.&lt;/span&gt;
&lt;span class="nv"&gt;LATEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;v24.22.2 &lt;span class="c"&gt;# the newest 24.x when you start the watcher&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$(&lt;/span&gt;seq 1 64&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="c"&gt;# index.json is sorted newest first, so the first v24.* entry is the latest 24.x&lt;/span&gt;
  &lt;span class="nv"&gt;v&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;curl -s -m 20 https://nodejs.org/dist/index.json &lt;span class="o"&gt;|&lt;/span&gt; python3 -c &lt;span class="s1"&gt;'import sys,json; d=json.load(sys.stdin); print(next(x["version"] for x in d if x["version"].startswith("v24.")))'&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;-n &lt;span class="s2"&gt;"$v"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"$v"&lt;/span&gt; != &lt;span class="s2"&gt;"$LATEST"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NEW NODE 24: $v ($(date -u +%H:%M) UTC)"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 0&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
  sleep 900
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no new Node 24 release after 16 h"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;release day&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# start it in the background and forget about it&lt;/span&gt;
$ chmod +x node-release-watch.sh
$ nohup ./node-release-watch.sh &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; node-watch.log 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1 &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;
&lt;span class="c"&gt;# hours later&lt;/span&gt;
$ cat node-watch.log
NEW NODE 24: v24.22.3 &lt;span class="o"&gt;(&lt;/span&gt;16:58 UTC&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes if you adapt it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A failed &lt;code&gt;curl&lt;/code&gt; or a bad JSON response leaves &lt;code&gt;v&lt;/code&gt; empty, so a network blip does not trigger a false alert.&lt;/li&gt;
&lt;li&gt;Swap the &lt;code&gt;echo&lt;/code&gt; for a call to your chat webhook if you want a ping instead of a log line.&lt;/li&gt;
&lt;li&gt;To wake only for security releases, add &lt;code&gt;and x["security"]&lt;/code&gt; to the filter and set &lt;code&gt;LATEST&lt;/code&gt; to the newest 24.x security release.&lt;/li&gt;
&lt;li&gt;For the long term, subscribe to the low-volume &lt;code&gt;nodejs-sec&lt;/code&gt; mailing list and the &lt;a href="https://nodejs.org/en/feed/vulnerability.xml" rel="noopener noreferrer"&gt;vulnerability RSS feed&lt;/a&gt;. They tell you a release is coming. The watcher tells you the moment it is downloadable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How we upgraded
&lt;/h2&gt;

&lt;p&gt;Our Node apps run on a Linux VM. Node comes from nvm, pm2 supervises the apps, and the pm2 daemon starts at boot from the systemd unit that &lt;code&gt;pm2 startup&lt;/code&gt; generated. The timeline on 11 November, in UTC:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;16:58&lt;/strong&gt; watcher sees v24.22.3&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17:00&lt;/strong&gt; nvm install, pm2, dump backup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17:02&lt;/strong&gt; apps moved one by one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17:03&lt;/strong&gt; move the pm2 daemon&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;17:12&lt;/strong&gt; all on 24.22.3, smoke tests pass&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 1: install the new version and pm2
&lt;/h3&gt;

&lt;p&gt;As the app user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nvm install 24.22.3
nvm &lt;span class="nb"&gt;alias&lt;/span&gt; default 24.22.3
nvm use 24.22.3
npm install -g pm2@6.0.13 &lt;span class="c"&gt;# same pm2 version you run now; globals are per Node version under nvm&lt;/span&gt;
cp ~/.pm2/dump.pm2 ~/.pm2/dump.pm2.bak-&lt;span class="nv"&gt;$(&lt;/span&gt;date -u +%Y%m%dT%H%M&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under nvm, global packages live inside each Node version, so the new Node has no pm2 until you install it. The new &lt;code&gt;pm2&lt;/code&gt; CLI then talks to the old daemon, which is fine for now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: find out how each entry picks its Node
&lt;/h3&gt;

&lt;p&gt;This is the part that decides whether a restart moves an app. pm2 stores an interpreter per entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 jlist &lt;span class="o"&gt;|&lt;/span&gt; python3 -c &lt;span class="s1"&gt;'
import sys, json
for p in json.load(sys.stdin):
    e = p["pm2_env"]
    print(p["name"], e.get("exec_mode"), e.get("exec_interpreter"), e.get("node_version"), sep="\t")'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bare &lt;code&gt;node&lt;/code&gt;, fork mode:&lt;/strong&gt; resolved through &lt;code&gt;PATH&lt;/code&gt;. A restart with &lt;code&gt;--update-env&lt;/code&gt; picks up the new version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An absolute path&lt;/strong&gt; such as &lt;code&gt;/home/app/.nvm/versions/node/v24.22.2/bin/node&lt;/code&gt;: written in when the app was started with &lt;code&gt;--interpreter&lt;/code&gt;, or copied from the daemon's own Node. A restart reuses that path, so the entry has to be deleted and started again. If the ecosystem file hard-codes the path, fix the file first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster mode&lt;/strong&gt; (&lt;code&gt;exec_mode&lt;/code&gt; is &lt;code&gt;cluster_mode&lt;/code&gt;): the daemon forks the workers itself, so they run on the daemon's Node whatever the entry says. They move when the daemon moves in step 4.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3: move apps one at a time
&lt;/h3&gt;

&lt;p&gt;One app, one health check, then the next. If a check fails, you stop with one app affected and the rest still serving. Run it as a script, so &lt;code&gt;exit&lt;/code&gt; stops it at the first failure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;health&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="c"&gt;# health &amp;lt;name&amp;gt; &amp;lt;port&amp;gt; [path]: retry up to 30 times until a 200&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$(&lt;/span&gt;seq 1 30&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"$(curl -s -o /dev/null -w '%{http_code}' -m 5 "&lt;/span&gt;http://127.0.0.1:&lt;span class="nv"&gt;$2${3:-/}&lt;/span&gt;&lt;span class="s2"&gt;")"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 200&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;"$1 ok"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;return&lt;/span&gt; 0&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
    sleep 2
  &lt;span class="k"&gt;done&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$1 NOT healthy on :$2"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;return&lt;/span&gt; 1
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# Bare "node" interpreter: --update-env passes the new PATH&lt;/span&gt;
pm2 restart web --update-env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; health web 3000 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1

&lt;span class="c"&gt;# Absolute interpreter path: recreate the entry from the ecosystem file&lt;/span&gt;
pm2 delete api
pm2 start ecosystem.config.cjs --only api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; health api 3001 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1

pm2 save

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check what each process actually runs. A 200 only tells you the app is up, not which Node it is on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pm2 jlist &lt;span class="o"&gt;|&lt;/span&gt; python3 -c &lt;span class="s1"&gt;'import sys,json; [print(p["name"], p["pm2_env"].get("node_version"), p["pm2_env"]["status"], sep="\t") for p in json.load(sys.stdin)]'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two minutes in, every app was on 24.22.3 except one. That entry had no interpreter set, and in that case pm2 starts the script with the &lt;strong&gt;daemon's&lt;/strong&gt; Node. Cluster-mode apps behave the same way, since the daemon forks them itself. Restarting it changed nothing, because the daemon was still on the old version. So the daemon had to move too.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: move the pm2 daemon through systemd
&lt;/h3&gt;

&lt;p&gt;The unit that &lt;code&gt;pm2 startup&lt;/code&gt; writes has the Node version in it twice: in &lt;code&gt;Environment=PATH=&lt;/code&gt; and in the path to the pm2 binary. Point both at the new version, as root:&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;UNIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/systemd/system/pm2-app.service
cp &lt;span class="s2"&gt;"$UNIT"&lt;/span&gt; &lt;span class="s2"&gt;"$UNIT.bak"&lt;/span&gt;
sed -i &lt;span class="s1"&gt;'s#/node/v[0-9][0-9.]*/#/node/v24.22.3/#g'&lt;/span&gt; &lt;span class="s2"&gt;"$UNIT"&lt;/span&gt;
grep -n &lt;span class="s1"&gt;'/node/v'&lt;/span&gt; &lt;span class="s2"&gt;"$UNIT"&lt;/span&gt; &lt;span class="c"&gt;# every match should now say v24.22.3&lt;/span&gt;
systemctl daemon-reload

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart the daemon through the unit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# As the app user, with the new pm2 on PATH&lt;/span&gt;
pm2 save
cp ~/.pm2/dump.pm2 ~/.pm2/dump.pm2.pre-daemon
pm2 &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="c"&gt;# no daemon left for resurrect to attach to&lt;/span&gt;

&lt;span class="c"&gt;# As root&lt;/span&gt;
systemctl restart pm2-app &lt;span class="c"&gt;# ExecStart runs "pm2 resurrect" on the new Node&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pm2 kill&lt;/code&gt; stops every app, and &lt;code&gt;pm2 resurrect&lt;/code&gt; starts them all again from the dump, so plan for a short gap. Give it 30 to 60 seconds before you check.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't use &lt;code&gt;pm2 update&lt;/code&gt; for this. Restart the systemd unit instead.&lt;/strong&gt; &lt;code&gt;pm2 update&lt;/code&gt; relaunches the daemon from your shell, on the Node that runs the pm2 CLI there. From a login shell or &lt;code&gt;sudo&lt;/code&gt;, that can be an older version still installed on the box, and the new daemon then runs outside the unit that starts it at boot. The unit has the version written into it and is the same path a reboot takes, so restarting it gives the same result every time. If the daemon ever comes back without your apps: &lt;code&gt;pm2 kill&lt;/code&gt;, copy &lt;code&gt;dump.pm2.pre-daemon&lt;/code&gt; back to &lt;code&gt;~/.pm2/dump.pm2&lt;/code&gt;, and restart the unit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5: verify from /proc, then smoke test
&lt;/h3&gt;

&lt;p&gt;pm2's view is a good start. The kernel's view is the one that counts. As root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The real binary of every Node process on the host, grouped&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; p &lt;span class="k"&gt;in&lt;/span&gt; /proc/&lt;span class="o"&gt;[&lt;/span&gt;0-9&lt;span class="o"&gt;]&lt;/span&gt;*&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nv"&gt;exe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;readlink &lt;span class="s2"&gt;"$p/exe"&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;continue&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"$exe"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; */node&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$exe"&lt;/span&gt; &lt;span class="o"&gt;;&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; sort &lt;span class="o"&gt;|&lt;/span&gt; uniq -c

&lt;span class="c"&gt;# The daemon itself&lt;/span&gt;
readlink &lt;span class="s2"&gt;"/proc/$(cat /home/app/.pm2/pm2.pid)/exe"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line should end in &lt;code&gt;v24.22.3/bin/node&lt;/code&gt;. Then run the external smoke tests against the public URLs, not localhost. Ours passed at 17:12 UTC, 14 minutes after the watcher fired.&lt;/p&gt;

&lt;p&gt;Once the new version has run cleanly for a day, remove the old ones with &lt;code&gt;nvm uninstall&lt;/code&gt;. An old Node left on the box is something a shell, a script or a daemon can fall back to without telling you. Until you remove it, check &lt;code&gt;which node&lt;/code&gt; in every shell you work from, including root's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the fleet
&lt;/h2&gt;

&lt;p&gt;The production host is the urgent part. The rest follows on its own schedule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI:&lt;/strong&gt; &lt;code&gt;actions/setup-node&lt;/code&gt; with &lt;code&gt;node-version: 24&lt;/code&gt; picks up 24.22.3 without workflow changes once the runner's tool cache has it. If you want it today rather than when the runner image refreshes, pin &lt;code&gt;node-version: '24.22.3'&lt;/code&gt;. &lt;code&gt;check-latest: true&lt;/code&gt; helps too, but the version manifest it reads can lag nodejs.org.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container images:&lt;/strong&gt; they need a rebuild. At the time of writing, Docker Hub had no &lt;code&gt;24.22.3-slim&lt;/code&gt; tag yet and &lt;code&gt;node:24-slim&lt;/code&gt; was last updated on 5 November, so a rebuild right now would still get the old Node. Check before you rebuild, then use &lt;code&gt;docker build --pull&lt;/code&gt; and confirm with &lt;code&gt;docker run --rm --pull always node:24-slim node -v&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s https://hub.docker.com/v2/repositories/library/node/tags/24.22.3-slim &lt;span class="o"&gt;|&lt;/span&gt;
  python3 -c &lt;span class="s1"&gt;'import sys,json; print(json.load(sys.stdin).get("last_updated", "not published yet"))'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dev machines:&lt;/strong&gt; last, since they do not serve untrusted traffic. &lt;code&gt;nvm install 24.22.3 &amp;amp;&amp;amp; nvm alias default 24.22.3&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a full audit of every place Node hides on a host (cron jobs, helper scripts, child processes, systemd units with old paths), see our &lt;a href="https://dev.to/devopsdaily/nodejs-october-2026-security-release-finding-every-node-you-actually-run-1ag4"&gt;October 2026 post on finding every Node you run&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist for the next Node.js security release
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;When the pre-alert lands&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Note the date, the affected lines and the highest severity.&lt;/li&gt;
&lt;li&gt;List every host, image and pipeline that runs those lines.&lt;/li&gt;
&lt;li&gt;Run the exposure checks above to decide the order.&lt;/li&gt;
&lt;li&gt;Check each pm2 entry's interpreter, and fix ecosystem files that hard-code a Node path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;On release day&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start the watcher in the morning, and confirm the fixed versions and &lt;code&gt;"security": true&lt;/code&gt; in &lt;code&gt;dist/index.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nvm install&lt;/code&gt; the new version, &lt;code&gt;nvm alias default&lt;/code&gt;, reinstall pm2 under it.&lt;/li&gt;
&lt;li&gt;Back up &lt;code&gt;~/.pm2/dump.pm2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Move apps one at a time: &lt;code&gt;restart --update-env&lt;/code&gt; for bare &lt;code&gt;node&lt;/code&gt;, delete and start for absolute paths. Health check each one, then &lt;code&gt;pm2 save&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;node_version&lt;/code&gt; in &lt;code&gt;pm2 jlist&lt;/code&gt; for every app.&lt;/li&gt;
&lt;li&gt;Point the systemd unit at the new version, &lt;code&gt;daemon-reload&lt;/code&gt;, &lt;code&gt;pm2 kill&lt;/code&gt;, &lt;code&gt;systemctl restart&lt;/code&gt;. Never &lt;code&gt;pm2 update&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Verify from &lt;code&gt;/proc&lt;/code&gt;, then run external smoke tests.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rebuild container images once the new tag is on Docker Hub, with &lt;code&gt;--pull&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make sure CI runs the new version (pin the exact version on release day).&lt;/li&gt;
&lt;li&gt;Update dev machines.&lt;/li&gt;
&lt;li&gt;After a day on the new version, &lt;code&gt;nvm uninstall&lt;/code&gt; the old ones so nothing can fall back to them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The watcher is what made this fast: it cost nothing to run, and we started within minutes of the release instead of hours. The per-app health checks made every step safe to stop. Keep the checklist somewhere you will find it next time.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/node-november-2026-security-release-track-and-patch" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>cve</category>
      <category>pm2</category>
    </item>
    <item>
      <title>We Gave an AI Agent a read_only Neon Role. Here Is What It Blocked</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 11 Nov 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/we-gave-an-ai-agent-a-readonly-neon-role-here-is-what-it-blocked-o07</link>
      <guid>https://dev.to/devopsdaily/we-gave-an-ai-agent-a-readonly-neon-role-here-is-what-it-blocked-o07</guid>
      <description>&lt;p&gt;Teams now connect coding agents to their databases through the Neon MCP Server, and "read-only" is the control they reach for first. We wanted to know what that control stops. We created a &lt;code&gt;read_only&lt;/code&gt; role with the Neon CLI and ran 40 one-statement probes against a Lakebase Postgres branch in four setups: a direct connection as that role, and three configurations of the Neon MCP Server. &lt;strong&gt;Every &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; and schema change we sent failed in every read-only setup.&lt;/strong&gt; Over the direct connection, 23 of the 40 probes still ran, and 9 of them left something behind. Four were durable writes from a role named &lt;code&gt;read_only&lt;/code&gt;: two rows written through a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function (one of them through &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;), a large object, and a setting stored on the role itself. The combination in Neon's docs, the MCP Server's read-only mode with the role pinned, stopped all four.&lt;/p&gt;

&lt;p&gt;The bigger risk was not a write. A 15-second read from the agent held a routine &lt;code&gt;ALTER TABLE&lt;/code&gt; for 11.3 to 12.8 seconds, and all four of the app's writers were stuck behind it, the longest insert for just as long. Sending the same read to a read replica brought the migration down to 0.14 to 0.16 seconds. But nothing in the role or the MCP Server URL puts the agent on that replica: the URL can pin a role, not a compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Neon MCP Server with &lt;code&gt;readonly=true&lt;/code&gt; and a pinned &lt;code&gt;read_only&lt;/code&gt; role stopped every durable write we tried. That is the setup to give an agent.&lt;/li&gt;
&lt;li&gt;Almost everything that still ran over a direct connection comes from standard Postgres defaults granted to &lt;code&gt;PUBLIC&lt;/code&gt;, not from Neon. The same probes behave the same way on any Postgres, and a few &lt;code&gt;REVOKE&lt;/code&gt; statements close most of them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;neon roles create --access read_only&lt;/code&gt; runs plain SQL as the database owner: &lt;code&gt;SELECT&lt;/code&gt; on the tables that exist, &lt;code&gt;USAGE&lt;/code&gt; on the schema, and default privileges for the owner's future tables. It does not touch what Postgres grants to &lt;code&gt;PUBLIC&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Over a direct connection, that role could still create temp tables, call a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function that writes, create a large object, change its own role settings, send &lt;code&gt;NOTIFY&lt;/code&gt;, take advisory locks, and read a signing key from a function body.&lt;/li&gt;
&lt;li&gt;The Neon MCP Server with &lt;code&gt;readonly=true&amp;amp;roleName=&amp;lt;role&amp;gt;&lt;/code&gt; wraps each statement in a &lt;code&gt;READ ONLY&lt;/code&gt; transaction and logs in as the pinned role. That blocked every durable write. &lt;code&gt;NOTIFY&lt;/code&gt;, advisory locks and reading function source still worked.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;readonly=true&lt;/code&gt; without a role pin runs as the owner. It still cannot write, but it ran &lt;code&gt;REINDEX&lt;/code&gt;, took an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock and could read the query text of every other session.&lt;/li&gt;
&lt;li&gt;The incident to plan for is lock queueing. Put agent reads on a read replica and give every migration a &lt;code&gt;lock_timeout&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A Neon account and an API key. The demo creates one throwaway project and deletes it at the end.&lt;/li&gt;
&lt;li&gt;Node.js 22 or newer.&lt;/li&gt;
&lt;li&gt;Basic Postgres privileges: roles, &lt;code&gt;GRANT&lt;/code&gt;, and what &lt;code&gt;PUBLIC&lt;/code&gt; means.&lt;/li&gt;
&lt;li&gt;A few cents of compute. Neon's consumption history API, which we read after the run, shows 0.39 CU-hours for our whole build, about $0.09 at the Scale list price, including three full runs of the matrix and the runs we threw away. That figure is not one of the repo's stamped results.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three layers, three different things
&lt;/h2&gt;

&lt;p&gt;A Neon setup for an agent has three controls, and they work at different levels. Mixing them up is where the surprises in this post come from.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Coding agent&lt;/strong&gt; MCP client or a connection string&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection string&lt;/strong&gt; logs in as agent_ro&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neon MCP Server&lt;/strong&gt; readonly=true&amp;amp;roleName=agent_ro&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;READ ONLY transaction&lt;/strong&gt; a Postgres transaction mode&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role privileges&lt;/strong&gt; what --access read_only granted&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lakebase Postgres&lt;/strong&gt; branch compute&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Connections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Coding agent -&amp;gt; Connection string&lt;/li&gt;
&lt;li&gt;Coding agent -&amp;gt; Neon MCP Server&lt;/li&gt;
&lt;li&gt;Neon MCP Server -&amp;gt; READ ONLY transaction (each run_sql)&lt;/li&gt;
&lt;li&gt;READ ONLY transaction -&amp;gt; Role privileges&lt;/li&gt;
&lt;li&gt;Connection string -&amp;gt; Role privileges (autocommit)&lt;/li&gt;
&lt;li&gt;Role privileges -&amp;gt; Lakebase Postgres&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The read_only role is SQL that the CLI runs
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--access&lt;/code&gt; is a feature of the Neon CLI (8.18.0 and newer) and of &lt;code&gt;@neon/sdk&lt;/code&gt;, not a field of the REST API, whose role request has only a name and a &lt;code&gt;no_login&lt;/code&gt; flag. For a scoped role, the SDK connects as the database owner and creates the role with SQL, in one transaction. The role starts as &lt;code&gt;NOLOGIN&lt;/code&gt; with a throwaway password, Neon's API then sets the real password, and only then does the SDK turn on login. This is the list from &lt;code&gt;roleAccessStatements()&lt;/code&gt; in &lt;code&gt;@neon/sdk&lt;/code&gt;, with our names filled in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="s2"&gt;"agent_ro"&lt;/span&gt; &lt;span class="no"&gt;NOLOGIN&lt;/span&gt; &lt;span class="no"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;bootstrap&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="no"&gt;CONNECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="s2"&gt;"neondb"&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s2"&gt;"agent_ro"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="no"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s2"&gt;"agent_ro"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="no"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s2"&gt;"agent_ro"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="no"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="s2"&gt;"neondb_owner"&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt;
  &lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="no"&gt;TABLES&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s2"&gt;"agent_ro"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- after Neon replaces the bootstrap password:&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="s2"&gt;"agent_ro"&lt;/span&gt; &lt;span class="no"&gt;LOGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;--schema&lt;/code&gt;, the schema is &lt;code&gt;public&lt;/code&gt;. Without &lt;code&gt;--access&lt;/code&gt;, the role joins &lt;code&gt;neon_superuser&lt;/code&gt;, which is admin on every database. The SDK's own comment states the limit that matters later: default privileges "cover tables the owner creates later, not tables other roles own".&lt;/p&gt;

&lt;p&gt;This is the real run. The CLI prints the new role's password, and our script drops that field before anything is written:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;neon CLI 9.3.2&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;$ npx -y neon@9.3.2 roles create --project-id shy-bread-81087327 --branch br-green-rain-bdgi2170 --database neondb --name agent_ro --access read_only --schema app --output json
&lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"branch_id"&lt;/span&gt;: &lt;span class="s2"&gt;"br-green-rain-bdgi2170"&lt;/span&gt;,
  &lt;span class="s2"&gt;"name"&lt;/span&gt;: &lt;span class="s2"&gt;"agent_ro"&lt;/span&gt;,
  &lt;span class="s2"&gt;"protected"&lt;/span&gt;: false,
  &lt;span class="s2"&gt;"authentication_method"&lt;/span&gt;: &lt;span class="s2"&gt;"password"&lt;/span&gt;,
  &lt;span class="s2"&gt;"created_at"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-11-11T07:44:31Z"&lt;/span&gt;,
  &lt;span class="s2"&gt;"updated_at"&lt;/span&gt;: &lt;span class="s2"&gt;"2026-11-11T07:44:31Z"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catalog afterwards matches the list exactly: no role memberships, &lt;code&gt;SELECT&lt;/code&gt; on every table in &lt;code&gt;app&lt;/code&gt;, one default ACL for the owner's future tables. The line that explains several results below is the database ACL, &lt;code&gt;=Tc/neondb_owner&lt;/code&gt;. The empty name before &lt;code&gt;=&lt;/code&gt; is &lt;code&gt;PUBLIC&lt;/code&gt;, and &lt;code&gt;Tc&lt;/code&gt; is &lt;code&gt;TEMPORARY&lt;/code&gt; and &lt;code&gt;CONNECT&lt;/code&gt;. Postgres grants those to every role by default, and it grants &lt;code&gt;EXECUTE&lt;/code&gt; on new functions to &lt;code&gt;PUBLIC&lt;/code&gt; too. The CLI does not revoke either, and it should not have to: changing database-wide defaults is not what "create a role" means.&lt;/p&gt;

&lt;h3&gt;
  
  
  readonly=true wraps each statement in a READ ONLY transaction
&lt;/h3&gt;

&lt;p&gt;On the hosted Neon MCP Server, &lt;code&gt;readonly=true&lt;/code&gt; (or the older &lt;code&gt;x-read-only&lt;/code&gt; header) makes &lt;code&gt;run_sql&lt;/code&gt; send the agent's statement through the Neon serverless driver as a one-statement transaction with &lt;code&gt;readOnly: true&lt;/code&gt;. Postgres then refuses writes with SQLSTATE &lt;code&gt;25006&lt;/code&gt;. The server also removes &lt;code&gt;run_sql_transaction&lt;/code&gt; in this mode, because several caller statements in one shared transaction could end the read-only transaction early. In our runs, the read-only connection listed 59 tools and did not include &lt;code&gt;run_sql_transaction&lt;/code&gt;. The &lt;code&gt;read_write&lt;/code&gt; connection listed 140.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;READ ONLY&lt;/code&gt; is a transaction mode, not a privilege boundary. The Postgres docs list what it blocks: data changes, &lt;code&gt;CREATE&lt;/code&gt;, &lt;code&gt;ALTER&lt;/code&gt; and &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;COMMENT&lt;/code&gt;, &lt;code&gt;GRANT&lt;/code&gt;, &lt;code&gt;REVOKE&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;, and &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; or &lt;code&gt;EXECUTE&lt;/code&gt; of any of those. They call it "a high-level notion of read-only that does not prevent all writes to disk". The list does not mention &lt;code&gt;NOTIFY&lt;/code&gt;, advisory locks, &lt;code&gt;LOCK TABLE&lt;/code&gt;, &lt;code&gt;REINDEX&lt;/code&gt; or &lt;code&gt;SET&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  roleName pins every SQL tool to one role
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;roleName=agent_ro&lt;/code&gt; on the server URL makes every SQL tool log in as that role. There is no fallback: a &lt;code&gt;role_name&lt;/code&gt; argument that names another role is refused, and with a pin in place the server also refuses to create scoped roles, because that runs SQL as the owner. The pin covers SQL only, so Neon's docs suggest combining it with &lt;code&gt;readonly=true&lt;/code&gt; and &lt;code&gt;category&lt;/code&gt; to limit the Management API tools as well. The same page says: "We recommend MCP for development and testing only, not production environments."&lt;/p&gt;

&lt;p&gt;One gap in the controls: &lt;code&gt;run_sql&lt;/code&gt; also takes a &lt;code&gt;compute_id&lt;/code&gt;. In the server source, the URL options are &lt;code&gt;readonly&lt;/code&gt;, &lt;code&gt;category&lt;/code&gt;, &lt;code&gt;projectId&lt;/code&gt; and &lt;code&gt;roleName&lt;/code&gt;, so a compute cannot be pinned there. The agent chooses whether to read from the primary or from a replica, and your configuration cannot make it use the replica.&lt;/p&gt;

&lt;h2&gt;
  
  
  The probe matrix
&lt;/h2&gt;

&lt;p&gt;We seeded a small fake SaaS database (500 customers, 5,000 orders, 10,098 order items, an audit log, a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function that writes to it, and a function with a fake webhook signing key in its body). Then we ran 40 probes in four setups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;S1&lt;/strong&gt; : a direct connection as &lt;code&gt;agent_ro&lt;/code&gt;, what an agent gets if you hand it a connection string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S2&lt;/strong&gt; : the Neon MCP Server with &lt;code&gt;readonly=true&lt;/code&gt; and no role pin, so it runs as the owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3&lt;/strong&gt; : &lt;code&gt;readonly=true&amp;amp;roleName=agent_ro&lt;/code&gt;, the combination Neon's docs show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S4&lt;/strong&gt; : &lt;code&gt;roleName=agent_rw&lt;/code&gt;, a &lt;code&gt;--access read_write&lt;/code&gt; role with writes on, as a baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MCP probes go through the hosted server with the MCP SDK client and an API key, the same calls an agent makes. No model is involved, so every probe is exactly the statement we wrote.&lt;/p&gt;

&lt;p&gt;Each probe declared its expected result for each setup before the run: allowed, or denied with an exact SQLSTATE. A result that did not match was a harness failure, not a pass. After each probe, an owner connection checked for a trace: a new row in the audit log, a new large object, a new role setting, or a &lt;code&gt;NOTIFY&lt;/code&gt; that a listening session received. Temp tables and session advisory locks were checked inside the probe's own session on the direct connection.&lt;/p&gt;

&lt;p&gt;The final run matched its declared expectations in all 160 probe-runs. Two earlier complete runs are kept in &lt;code&gt;results/discarded/&lt;/code&gt;, and &lt;code&gt;scripts/compare-matrix-runs.mjs&lt;/code&gt; shows that all three agree on every probe's result in all four setups: allowed or refused, the SQLSTATE, and the trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;40 probes per setup: not blocked, and how many left a trace&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;left a trace&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S1 direct, read_only role&lt;/td&gt;
&lt;td&gt;23 probes&lt;/td&gt;
&lt;td&gt;9 probes&lt;/td&gt;
&lt;td&gt;direct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S2 MCP readonly, no role pin&lt;/td&gt;
&lt;td&gt;21 probes&lt;/td&gt;
&lt;td&gt;3 probes&lt;/td&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 MCP readonly + roleName&lt;/td&gt;
&lt;td&gt;18 probes&lt;/td&gt;
&lt;td&gt;3 probes&lt;/td&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S4 MCP read_write role&lt;/td&gt;
&lt;td&gt;27 probes&lt;/td&gt;
&lt;td&gt;8 probes&lt;/td&gt;
&lt;td&gt;write baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Bars: probes that ran without an error. Ticks: probes that left a trace (a durable write, a temp table or advisory lock, or a NOTIFY another session received). In S2 to S4, temp-table and advisory-lock traces are inferred from a successful call. Final run, Lakebase Postgres 18, aws-us-east-2.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is every probe. &lt;code&gt;ok&lt;/code&gt; means it ran, &lt;code&gt;ok*&lt;/code&gt; means it ran and left a trace, and a code means it was refused with that SQLSTATE. Durable writes and &lt;code&gt;NOTIFY&lt;/code&gt; were checked from an owner session in every setup. Temp tables and advisory locks were checked inside the session only on the direct connection (S1). In S2 to S4 those two marks are inferred from a successful call.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;S1 direct&lt;/th&gt;
&lt;th&gt;S2 no pin&lt;/th&gt;
&lt;th&gt;S3 pin&lt;/th&gt;
&lt;th&gt;S4 rw&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; a row&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TRUNCATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CREATE TABLE&lt;/code&gt;, &lt;code&gt;ALTER TABLE&lt;/code&gt;, &lt;code&gt;DROP INDEX&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nextval()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;setval()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CREATE TEMP TABLE&lt;/code&gt; (2 probes)&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SECURITY DEFINER&lt;/code&gt; function that writes&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, through &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;lo_from_bytea()&lt;/code&gt; large object&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER ROLE agent_ro SET statement_timeout = 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NOTIFY&lt;/code&gt;, &lt;code&gt;pg_notify()&lt;/code&gt; (2 probes)&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_advisory_lock()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;td&gt;ok*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other advisory lock calls (3 probes)&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOCK TABLE ... ACCESS SHARE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;25P01&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;25P01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LOCK TABLE ... ACCESS EXCLUSIVE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;25P01&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;25P01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Function body with the signing key (&lt;code&gt;pg_proc.prosrc&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer emails, role list, settings (4 probes)&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other sessions' query text&lt;/td&gt;
&lt;td&gt;ok, 0 rows&lt;/td&gt;
&lt;td&gt;ok, 11 rows&lt;/td&gt;
&lt;td&gt;ok, 0 rows&lt;/td&gt;
&lt;td&gt;ok, 0 rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SET statement_timeout = 0&lt;/code&gt;, &lt;code&gt;SET work_mem&lt;/code&gt;, &lt;code&gt;RESET ALL&lt;/code&gt;, &lt;code&gt;LISTEN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;generate_series&lt;/code&gt; sum over 2 million rows&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SET ROLE neondb_owner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_read_file()&lt;/code&gt;, &lt;code&gt;pg_ls_dir()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE EXTENSION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;25006&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REINDEX TABLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;td&gt;42501&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What the direct connection left open
&lt;/h3&gt;

&lt;p&gt;S1 is the case to worry about, because a connection string is the easiest thing to give an agent and the hardest to take back. The role blocked what it was built to block: every row write, every schema change, sequences, &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;, and every route to more privilege (&lt;code&gt;SET ROLE&lt;/code&gt;, server files, extensions). Most of the 23 probes that ran are reads and session settings that a read-only role is expected to allow.&lt;/p&gt;

&lt;p&gt;The nine traces are the interesting part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Four durable writes.&lt;/strong&gt; &lt;code&gt;app.record_touch()&lt;/code&gt; is a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function owned by the owner, and &lt;code&gt;PUBLIC&lt;/code&gt; can execute it. Calling it wrote a row to &lt;code&gt;app.audit_log&lt;/code&gt; with no table privilege at all. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; runs the statement it explains, so the same call through &lt;code&gt;EXPLAIN&lt;/code&gt; wrote a second row. &lt;code&gt;lo_from_bytea()&lt;/code&gt; created a large object, which needs no grant by default. And &lt;code&gt;ALTER ROLE agent_ro SET statement_timeout = 0&lt;/code&gt; stored a setting on the role in the catalog, where it applies to every future session of that role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three session effects.&lt;/strong&gt; Two temp tables (one filled with 100 order IDs) and a session-level advisory lock. They disappear when the session ends, but a long-lived agent session can hold an advisory lock that blocks your app's own locks for as long as it likes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two signals.&lt;/strong&gt; &lt;code&gt;NOTIFY&lt;/code&gt; and &lt;code&gt;pg_notify()&lt;/code&gt; reached a session that was listening on the channel. If your app uses &lt;code&gt;LISTEN&lt;/code&gt; for job queues or cache invalidation, a read-only agent can trigger it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There was also one disclosure without a trace: the function body with &lt;code&gt;signing_key text := 'whsec_demo_...'&lt;/code&gt; came back from &lt;code&gt;pg_proc.prosrc&lt;/code&gt;. Postgres makes the &lt;code&gt;pg_proc&lt;/code&gt; catalog readable by every role by default, so a secret in a function body is readable by any role that can connect. Ours came back in all four setups.&lt;/p&gt;

&lt;p&gt;None of this is a Neon bug. Each item is standard Postgres behavior: &lt;code&gt;PUBLIC&lt;/code&gt; holds &lt;code&gt;TEMP&lt;/code&gt; on the database and &lt;code&gt;EXECUTE&lt;/code&gt; on functions by default, a large object needs no grant, the &lt;code&gt;NOTIFY&lt;/code&gt; statement needs no privilege, and &lt;code&gt;pg_notify()&lt;/code&gt; and the advisory-lock functions run on the &lt;code&gt;EXECUTE&lt;/code&gt; that &lt;code&gt;PUBLIC&lt;/code&gt; holds on built-in functions. It took a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function in our schema to turn &lt;code&gt;EXECUTE&lt;/code&gt; into a write, and many real schemas have one. The role does what it says on the label; it just does not say "sandbox".&lt;/p&gt;

&lt;h3&gt;
  
  
  What the MCP read-only mode closed, and what it did not
&lt;/h3&gt;

&lt;p&gt;S3 refused all four durable writes and both temp tables with &lt;code&gt;25006&lt;/code&gt;. The pinned role kept the privilege checks too: &lt;code&gt;nextval()&lt;/code&gt;, &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; and &lt;code&gt;SET ROLE&lt;/code&gt; failed with &lt;code&gt;42501&lt;/code&gt; before the read-only check ran. Three traces remained. The two &lt;code&gt;NOTIFY&lt;/code&gt; probes reached our listening session. &lt;code&gt;pg_advisory_lock()&lt;/code&gt; succeeded, and since that function returns only once it holds the lock, the lock was taken. We did not observe it from another session, because each &lt;code&gt;run_sql&lt;/code&gt; call is a separate request to the MCP Server and we had no way to look while it was held.&lt;/p&gt;

&lt;p&gt;The function body with the signing key came back in S3 as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  Without a role pin, readonly=true runs as the owner
&lt;/h3&gt;

&lt;p&gt;S2 shows why Neon pairs &lt;code&gt;readonly=true&lt;/code&gt; with &lt;code&gt;roleName&lt;/code&gt;. Without a pin, the server logs in as the database owner, and only the transaction mode stands between the agent and your data. It still refused every &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; and schema change we sent, and all four probes that wrote durably over the direct connection. But as the owner, the agent could run &lt;code&gt;REINDEX TABLE&lt;/code&gt;, take an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock and &lt;code&gt;SET ROLE&lt;/code&gt; to the owner. The owner is also a member of &lt;code&gt;pg_read_all_stats&lt;/code&gt;, so &lt;code&gt;pg_stat_activity&lt;/code&gt; showed it the query text of every other client session, the app's and the &lt;code&gt;read_only&lt;/code&gt; agent's included. Our probe counted 11 visible rows for other sessions, some of them background processes with no query. The direct &lt;code&gt;read_only&lt;/code&gt; role saw none. Query text often holds literal values: emails in a &lt;code&gt;WHERE&lt;/code&gt; clause, tokens in an &lt;code&gt;INSERT&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  We got 9 predictions wrong
&lt;/h3&gt;

&lt;p&gt;Our first runs disagreed with our declared expectations in 9 cells. Each disagreement is a small lesson about Postgres:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LOCK TABLE&lt;/code&gt; needs a transaction block. As a single autocommit statement, on the direct connection or the &lt;code&gt;read_write&lt;/code&gt; MCP connection, it fails with &lt;code&gt;25P01&lt;/code&gt;. The read-only MCP wrapper supplies the transaction, so there it runs, and the privilege decides.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;REINDEX&lt;/code&gt; is not on the list a &lt;code&gt;READ ONLY&lt;/code&gt; transaction blocks, so the owner ran it in S2.&lt;/li&gt;
&lt;li&gt;The order of checks differs by statement. With the pinned role, &lt;code&gt;nextval()&lt;/code&gt; fails the privilege check (&lt;code&gt;42501&lt;/code&gt;) first, while &lt;code&gt;CREATE EXTENSION&lt;/code&gt; fails the read-only check (&lt;code&gt;25006&lt;/code&gt;) first.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; takes row locks, so &lt;code&gt;READ ONLY&lt;/code&gt; refuses it even for the owner.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We changed the predictions, never the outcomes, and the reason for each is a comment in &lt;code&gt;lib/probes.mjs&lt;/code&gt;. Two other mismatches were harness bugs: a &lt;code&gt;DELETE&lt;/code&gt; that hit a foreign key, and an &lt;code&gt;INSERT&lt;/code&gt; that collided with a row from the previous run. We fixed the probe and added cleanup, and neither one hid a result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident one: a read that stalls a migration
&lt;/h2&gt;

&lt;p&gt;A read takes an &lt;code&gt;ACCESS SHARE&lt;/code&gt; lock on every table it touches and holds it until its transaction ends. &lt;code&gt;ALTER TABLE ... ADD COLUMN&lt;/code&gt; needs &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, so it waits for the read. Then Postgres queues every new lock request on that table behind the waiting &lt;code&gt;ALTER&lt;/code&gt;, including the app's ordinary inserts. The agent never writes anything, and your app still stops writing.&lt;/p&gt;

&lt;p&gt;The setup: four app writers insert into &lt;code&gt;app.orders&lt;/code&gt; through the pooled connection string, one insert per writer every 50 ms. At 2 seconds the agent runs a 15-second read on &lt;code&gt;app.orders&lt;/code&gt; through &lt;code&gt;run_sql&lt;/code&gt; (S3). At 4 seconds the owner runs &lt;code&gt;ALTER TABLE app.orders ADD COLUMN note text&lt;/code&gt; without a &lt;code&gt;lock_timeout&lt;/code&gt;. A direct owner connection samples &lt;code&gt;pg_locks&lt;/code&gt; every 250 ms. We ran three variants three times each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Migration&lt;/th&gt;
&lt;th&gt;Longest app insert&lt;/th&gt;
&lt;th&gt;Inserts over 1 s&lt;/th&gt;
&lt;th&gt;Backends waiting&lt;/th&gt;
&lt;th&gt;Inserts completed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent reads on the primary&lt;/td&gt;
&lt;td&gt;11.32 to 12.80 s&lt;/td&gt;
&lt;td&gt;11.29 to 12.70 s&lt;/td&gt;
&lt;td&gt;4 per run&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;104 to 124&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent reads on a read replica&lt;/td&gt;
&lt;td&gt;0.14 to 0.16 s&lt;/td&gt;
&lt;td&gt;0.17 to 0.51 s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;350 to 378&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary, migration with &lt;code&gt;lock_timeout = '2s'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;gave up at 2.13 s (&lt;code&gt;55P03&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;2.02 to 2.10 s&lt;/td&gt;
&lt;td&gt;4 per run&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;331 to 357&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ranges are over three runs of each variant. With nothing in the way, one insert took 128 to 167 ms at the median, which is the round trip from our test machine to aws-us-east-2 through the pooler. "Backends waiting" is the most lock requests on &lt;code&gt;app.orders&lt;/code&gt; that &lt;code&gt;pg_locks&lt;/code&gt; showed as not yet granted: the &lt;code&gt;ALTER&lt;/code&gt; and all four app writers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Longest app insert while the agent's 15-second read ran&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;migration time&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent reads on the primary&lt;/td&gt;
&lt;td&gt;12.39s&lt;/td&gt;
&lt;td&gt;12.43s&lt;/td&gt;
&lt;td&gt;primary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration with lock_timeout 2s&lt;/td&gt;
&lt;td&gt;2.09s&lt;/td&gt;
&lt;td&gt;2.13s&lt;/td&gt;
&lt;td&gt;lock_timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent reads on a read replica&lt;/td&gt;
&lt;td&gt;0.17s&lt;/td&gt;
&lt;td&gt;0.16s&lt;/td&gt;
&lt;td&gt;replica&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Median of 3 runs per variant. Bars: longest single insert from 4 app writers. Ticks: how long the ALTER TABLE took (or waited before lock_timeout gave up). Lakebase Postgres 18, aws-us-east-2.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The replica fix works because a read replica is a separate compute: a lock taken there is not a lock on the primary. It moves the conflict, it does not remove it. The &lt;code&gt;ALTER&lt;/code&gt; still has to be replayed on the replica, where it waits for the agent's read or cancels it. In all 3 replica runs, the agent's read finished without being cancelled.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lock_timeout&lt;/code&gt; is the fix on the migration side. The &lt;code&gt;ALTER&lt;/code&gt; gave up after 2.1 seconds with &lt;code&gt;55P03&lt;/code&gt; instead of holding the app for 12.8, and the migration tool can retry. The app still waited about 2 seconds, which is the price of finding out. This is the same advice as in &lt;a href="https://dev.to/devopsdaily/ci-passed-6-of-6-migrations-a-neon-branch-of-production-passed-1-42k4"&gt;rehearsing migrations on a Neon branch&lt;/a&gt;: every migration should set a &lt;code&gt;lock_timeout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our first version of this test got the incident wrong. The app writers inserted into a side table, not &lt;code&gt;app.orders&lt;/code&gt;, so their latency during the stall matched the baseline and the run seemed to show that only the migration waited. The migration numbers were right, the app numbers measured the wrong table. That run is kept in &lt;code&gt;results/discarded/&lt;/code&gt; in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incident two: one heavy read is cheap, and nothing bounds a loop
&lt;/h2&gt;

&lt;p&gt;The role does not limit how much a read may scan. We gave the agent one legal heavy read: a parallel scan of a 2-million-row, 137 MB table with a chain of 20 &lt;code&gt;md5()&lt;/code&gt; calls per row. It ran through the MCP Server as &lt;code&gt;agent_ro&lt;/code&gt;, first on the primary, allowed to autoscale from 0.25 to 4 CU, and then on a read replica capped at 1 CU. An owner connection on the same compute read &lt;code&gt;num_cpus()&lt;/code&gt; from the &lt;code&gt;neon_utils&lt;/code&gt; extension about once a second.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Compute&lt;/th&gt;
&lt;th&gt;Read time&lt;/th&gt;
&lt;th&gt;Peak vCPU&lt;/th&gt;
&lt;th&gt;vCPU-seconds, at most&lt;/th&gt;
&lt;th&gt;Scale cost, at most&lt;/th&gt;
&lt;th&gt;Launch cost, at most&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary, 0.25 to 4 CU&lt;/td&gt;
&lt;td&gt;25.8 s&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;32.84&lt;/td&gt;
&lt;td&gt;$0.00203&lt;/td&gt;
&lt;td&gt;$0.00097&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read replica, 0.25 to 1 CU&lt;/td&gt;
&lt;td&gt;29.2 s&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;31.71&lt;/td&gt;
&lt;td&gt;$0.00196&lt;/td&gt;
&lt;td&gt;$0.00093&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The primary reported 1 vCPU for most of the read and 2 vCPU only in the last four samples, about a second apart. &lt;code&gt;num_cpus()&lt;/code&gt; rounds up to a whole vCPU, so a compute at 0.25 CU reports 1. That makes these figures an upper bound: we multiply by Neon's list price of $0.222 per CU-hour on Scale ($0.106 on Launch) and assume every "1" was a full CU. We did not use the consumption API, because it lags a run by minutes.&lt;/p&gt;

&lt;p&gt;So one heavy read costs a fraction of a cent, and the capped replica was about 3 seconds slower for about the same cost. That is not the risk. The risk is that nothing in the role bounds how often the agent runs that read or how long a session stays open. The matrix showed that the agent can run &lt;code&gt;SET statement_timeout = 0&lt;/code&gt;, and on the direct connection it can store that on its own role. An agent that polls also keeps the compute from suspending; we measured that effect in &lt;a href="https://dev.to/devopsdaily/what-keeps-a-neon-compute-awake-and-what-it-costs-3p3h"&gt;what keeps a Neon compute awake&lt;/a&gt;. A capped replica for agents bounds the size of the compute, and a role-level &lt;code&gt;statement_timeout&lt;/code&gt; bounds the common case, as long as you remember that the session can lower it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift: when the role stops matching your schema
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;read_only&lt;/code&gt; role is a snapshot of the grants at the moment it was created. Two ordinary changes moved the schema away from it. Both failed closed, which is the safe direction, but both are the kind of thing that ends with someone granting more than they meant to.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A table created by another role.&lt;/strong&gt; Many teams run migrations as a separate role. We created a &lt;code&gt;migrator&lt;/code&gt; role and had it create &lt;code&gt;app.migrator_table&lt;/code&gt;. &lt;code&gt;agent_ro&lt;/code&gt; got &lt;code&gt;42501 permission denied&lt;/code&gt;, because the default privileges from &lt;code&gt;--access read_only&lt;/code&gt; are &lt;code&gt;FOR ROLE neondb_owner&lt;/code&gt;: they cover the owner's new tables and nobody else's. After &lt;code&gt;ALTER DEFAULT PRIVILEGES FOR ROLE migrator IN SCHEMA app GRANT SELECT ON TABLES TO agent_ro&lt;/code&gt;, the migrator's next table was readable. The table it had already created still returned &lt;code&gt;42501&lt;/code&gt;, because default privileges apply only to objects created after them. You need both the default privileges for the migration role and a one-time &lt;code&gt;GRANT&lt;/code&gt; for what already exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new schema.&lt;/strong&gt; &lt;code&gt;app2.secrets&lt;/code&gt; returned &lt;code&gt;42501 permission denied for schema app2&lt;/code&gt;, because the role was created with &lt;code&gt;--schema app&lt;/code&gt;. An agent that cannot see a schema may report that the data does not exist, which is a wrong answer rather than a leak. Pass &lt;code&gt;--schema&lt;/code&gt; once per schema, or re-run the grants when you add one.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Hardening, then the re-run
&lt;/h2&gt;

&lt;p&gt;This is the hardening we applied as the owner, from &lt;code&gt;schema/harden.sql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- TEMP and EXECUTE are PUBLIC defaults; take them back for everyone&lt;/span&gt;
&lt;span class="c1"&gt;-- and grant them only to the roles that run the app.&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;TEMPORARY&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;neondb&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="no"&gt;PUBLIC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;TEMPORARY&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;neondb&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;neondb_owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_rw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="no"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="no"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="no"&gt;PUBLIC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="no"&gt;PRIVILEGES&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;neondb_owner&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;
  &lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="no"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="no"&gt;FUNCTIONS&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="no"&gt;PUBLIC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="no"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record_touch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;neondb_owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_rw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Bounds for the agent role. These are defaults: a session can still&lt;/span&gt;
&lt;span class="c1"&gt;-- change statement_timeout with SET.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt; &lt;span class="no"&gt;CONNECTION&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="no"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;agent_ro&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;idle_in_transaction_session_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'15s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revoking &lt;code&gt;TEMPORARY&lt;/code&gt; from &lt;code&gt;PUBLIC&lt;/code&gt; affects every role that relied on the default, not only the agent, so grant it back to the roles that need it before you run this in production. The same goes for &lt;code&gt;EXECUTE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then we ran S1 and S3 again. On the direct connection, four probes flipped from allowed to &lt;code&gt;42501&lt;/code&gt;: both temp tables, the &lt;code&gt;SECURITY DEFINER&lt;/code&gt; write and its &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; twin. That took S1 from 23 probes that ran to 19, and from four durable writes to two. The two that stayed open have no grant to revoke: &lt;code&gt;lo_from_bytea()&lt;/code&gt; still created a large object, and &lt;code&gt;ALTER ROLE agent_ro SET statement_timeout = 0&lt;/code&gt; still ran. &lt;code&gt;NOTIFY&lt;/code&gt;, the advisory locks and the function body with the signing key were unchanged too: the &lt;code&gt;NOTIFY&lt;/code&gt; statement needs no privilege, the hardening revoked &lt;code&gt;EXECUTE&lt;/code&gt; only in the &lt;code&gt;app&lt;/code&gt; schema and not on built-in functions such as &lt;code&gt;pg_notify()&lt;/code&gt; and &lt;code&gt;pg_advisory_lock()&lt;/code&gt;, and &lt;code&gt;pg_proc&lt;/code&gt; stays readable.&lt;/p&gt;

&lt;p&gt;On the pinned MCP connection, nothing opened or closed. The &lt;code&gt;READ ONLY&lt;/code&gt; wrapper already refused everything the hardening addresses, and the only difference was the error: the &lt;code&gt;SECURITY DEFINER&lt;/code&gt; calls now failed with &lt;code&gt;42501&lt;/code&gt; (no &lt;code&gt;EXECUTE&lt;/code&gt;) instead of &lt;code&gt;25006&lt;/code&gt;, because the privilege check runs first.&lt;/p&gt;

&lt;p&gt;So the hardening matters for the connection-string case, and it is not enough on its own there. The two durable writes it cannot close are why a connection string is weaker than the same role behind the read-only MCP Server. One note on the re-run: the probe runner clears the role's &lt;code&gt;statement_timeout&lt;/code&gt; before it starts, so the 30-second default was not in place during the re-run. No probe depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One region, one Postgres version, one schema.&lt;/strong&gt; Lakebase Postgres 18 in aws-us-east-2, a small fake schema. A schema with more functions, extensions or roles has more &lt;code&gt;PUBLIC&lt;/code&gt; defaults to check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pooled connection string was not probed.&lt;/strong&gt; Our direct probes used the unpooled host. Through the pooler (PgBouncer in transaction mode), session effects such as &lt;code&gt;SET&lt;/code&gt;, temp tables and session advisory locks behave differently, because a session does not keep one backend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No escape attempts.&lt;/strong&gt; We probed what single statements can do. We did not try to get around the &lt;code&gt;READ ONLY&lt;/code&gt; wrapper or the role pin, beyond checking that &lt;code&gt;run_sql_transaction&lt;/code&gt; is gone and that &lt;code&gt;SET ROLE&lt;/code&gt; is refused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No model in the loop.&lt;/strong&gt; The probes are what a careless or misled agent could send, not what a given model chooses to send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small samples for the incidents.&lt;/strong&gt; Three runs per lock variant, one run per compute for cost. The direction is clear, but the exact seconds are not a benchmark. The cost figures are upper bounds because &lt;code&gt;num_cpus()&lt;/code&gt; rounds up.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Give agents the MCP Server with &lt;code&gt;readonly=true&amp;amp;roleName=&amp;lt;a read_only role&amp;gt;&lt;/code&gt;, not a connection string. In our runs that combination stopped every durable write.&lt;/li&gt;
&lt;li&gt;If an agent must have a connection string, revoke &lt;code&gt;TEMPORARY&lt;/code&gt; and &lt;code&gt;EXECUTE&lt;/code&gt; from &lt;code&gt;PUBLIC&lt;/code&gt; in the app's schemas first, and keep secrets out of function bodies.&lt;/li&gt;
&lt;li&gt;Keep agent reads on a read replica, and set a &lt;code&gt;lock_timeout&lt;/code&gt; in every migration. A lock taken on the replica is not a lock on the primary, and &lt;code&gt;lock_timeout&lt;/code&gt; makes a migration give up after a set time, so the writes queued behind it wait at most that long. The MCP URL cannot pin a compute, so the replica takes agent instructions that pass its &lt;code&gt;compute_id&lt;/code&gt;, or your own layer in front of the database. We did not run the probe matrix on a replica.&lt;/li&gt;
&lt;li&gt;Re-check grants after migrations that a different role runs, and pass &lt;code&gt;--schema&lt;/code&gt; for every schema the agent should read.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-read-only-agent" rel="noopener noreferrer"&gt;The-DevOps-Daily/neon-read-only-agent on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The repository has one script per stage, and each stage writes JSON lines to &lt;code&gt;results/&lt;/code&gt;, stamped with a hash of the code that produced them. &lt;code&gt;npm run verify-stamps&lt;/code&gt; checks every file against the commit in &lt;code&gt;results/STAMPED_AT&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;cp .env.example .env &lt;span class="c"&gt;# add a Neon API key&lt;/span&gt;
npm install
npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="c"&gt;# offline tests, no database&lt;/span&gt;
npm run project &lt;span class="c"&gt;# one throwaway project, Postgres 18&lt;/span&gt;
npm run seed &lt;span class="c"&gt;# fake orders schema and data&lt;/span&gt;
npm run roles &lt;span class="c"&gt;# neon roles create --access read_only / read_write&lt;/span&gt;
npm run matrix &lt;span class="c"&gt;# 40 probes x 4 setups&lt;/span&gt;
npm run lock-stall &lt;span class="c"&gt;# incident one&lt;/span&gt;
npm run cost &lt;span class="c"&gt;# incident two&lt;/span&gt;
npm run drift
npm run harden
npm run rerun &lt;span class="c"&gt;# S1 and S3 again after hardening&lt;/span&gt;
npm run summarize
npm run verify-stamps
npm run teardown &lt;span class="c"&gt;# deletes the project&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run is cheap: the incident stages are a few minutes of a 0.25 CU compute, and the cost stage briefly allows 4 CU. Run &lt;code&gt;teardown&lt;/code&gt; when you are done, because the read replica and the primary both count toward CU-hours while they are awake.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/neon-read-only-role-agent-probes" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>neon</category>
      <category>postgres</category>
      <category>mcp</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>EC2 Can Now Tell You Where IMDSv1 Is Still Enabled</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 10 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/ec2-can-now-tell-you-where-imdsv1-is-still-enabled-33m5</link>
      <guid>https://dev.to/devopsdaily/ec2-can-now-tell-you-where-imdsv1-is-still-enabled-33m5</guid>
      <description>&lt;p&gt;An EC2 instance can make no tokenless metadata requests all week and still allow them tomorrow. That distinction makes an IMDSv2 migration surprisingly easy to declare finished too early. AWS's new &lt;strong&gt;ImdsV1Enabled&lt;/strong&gt; CloudWatch metric gives you a configuration signal to put beside your usage graphs. You can use it to find the remaining exceptions, test one workload at a time, and catch settings that drift back.&lt;/p&gt;

&lt;p&gt;AWS &lt;a href="https://dev.to/mohammed_anes_6652d05cab7/amazon-ec2-now-publishes-imdsv1enabled-cloudwatch-metric-k8i-temp-slug-9564533"&gt;announced the metric on November 5, 2026&lt;/a&gt;, with availability in every AWS Region and no additional charge for the metric itself. It does not migrate instances for you. The useful change is that your migration dashboard can now answer whether IMDSv1 is still permitted, even when applications happen not to be using it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;AWS CLI v2 configured for the account and Region you want to inspect.&lt;/li&gt;
&lt;li&gt;Permission to describe EC2 instances and read CloudWatch metrics.&lt;/li&gt;
&lt;li&gt;A staging instance that represents the application, agents, and scheduled jobs you run in production.&lt;/li&gt;
&lt;li&gt;A workload owner available before you change metadata settings. The inventory commands below are read-only; the final change command is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three signals, three different questions
&lt;/h2&gt;

&lt;p&gt;AWS's &lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-metadata-transition-to-version-2.html" rel="noopener noreferrer"&gt;IMDSv2 transition guide&lt;/a&gt; defines the new metric as &lt;code&gt;1&lt;/code&gt; when HTTP tokens are optional and &lt;code&gt;0&lt;/code&gt; when they are required. Unlike the request counters, it is emitted from configuration rather than metadata access.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;How to use it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ImdsV1Enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Does this instance still allow tokenless access?&lt;/td&gt;
&lt;td&gt;Find configuration exceptions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MetadataNoToken&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Did a tokenless request succeed?&lt;/td&gt;
&lt;td&gt;Find callers that need migration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MetadataNoTokenRejected&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Was a tokenless request rejected after enforcement?&lt;/td&gt;
&lt;td&gt;Investigate compatibility problems.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two request metrics count successful tokenless calls and rejected attempts respectively; the &lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/viewing_metrics_with_cloudwatch.html" rel="noopener noreferrer"&gt;EC2 metrics reference&lt;/a&gt; documents both. A quiet request counter does not tell you which configuration is in place.&lt;/p&gt;

&lt;p&gt;Consider a backup task that runs once a month. A five-day observation window might never execute it. My recommendation is to keep an explicit exception list with an owner, the workload's longest relevant execution cycle, and the next test date. That makes a migration gap actionable instead of turning it into another permanently yellow dashboard.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find exceptions&lt;/strong&gt; Configuration still permits v1&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exercise callers&lt;/strong&gt; Boot, jobs, agents, containers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Require tokens&lt;/strong&gt; Verify health and rejected calls&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Build an inventory before changing anything
&lt;/h2&gt;

&lt;p&gt;Start with one account and Region. This example lists running and stopped instances whose metadata options still make tokens optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 describe-instances \
  --region eu-west-1 \
  --filters \
    &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;instance-state-name,Values=running,stopped \
    &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;metadata-options.http-tokens,Values=optional \
  --query &lt;span class="s1"&gt;'Reservations[].Instances[].{Id:InstanceId,State:State.Name,Endpoint:MetadataOptions.HttpEndpoint,Tokens:MetadataOptions.HttpTokens,HopLimit:MetadataOptions.HttpPutResponseHopLimit}'&lt;/span&gt; \
  --output table \
  --no-cli-pager

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/ec2/describe-instances.html" rel="noopener noreferrer"&gt;DescribeInstances API&lt;/a&gt; supports these filters and returns the metadata options. Keep the endpoint column: &lt;code&gt;HttpEndpoint=disabled&lt;/code&gt; is a separate state, not proof of a working IMDSv2 migration. Record those instances separately rather than enabling metadata access just to make every row look alike.&lt;/p&gt;

&lt;p&gt;Repeat the inventory for the accounts and Regions in your actual scope. An empty result in one Region is not a fleet-wide result. Include stopped instances in your review because they can return to service later.&lt;/p&gt;

&lt;p&gt;Then discover the metric series available to your caller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws cloudwatch list-metrics \
  --region eu-west-1 \
  --metric-name ImdsV1Enabled \
  --output json \
  --no-cli-pager

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command lists series and their dimensions, not current values. Use the returned series in CloudWatch to inspect datapoints. AWS documents that &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/cloudwatch/list-metrics.html" rel="noopener noreferrer"&gt;ListMetrics can take up to 15 minutes to show a new metric&lt;/a&gt;, and it omits series that have not reported in two weeks. An empty response is therefore a reason to check account, Region, visibility, and instance configuration, not a green migration result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the workload, then require IMDSv2
&lt;/h2&gt;

&lt;p&gt;Choose one representative staging instance and run its real lifecycle: bootstrapping, service restart, deployment, scheduled maintenance, and credentials refresh. Include monitoring and backup agents. For containers, test from inside the container as well as from the host.&lt;/p&gt;

&lt;p&gt;AWS notes that a metadata response hop limit of &lt;code&gt;1&lt;/code&gt; &lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-options.html" rel="noopener noreferrer"&gt;can cause problems in container environments&lt;/a&gt;. Do not change hop limits across the fleet just because a host-level test passed; investigate the affected network path separately.&lt;/p&gt;

&lt;p&gt;Once the workload is ready, replace the example ID and apply the token requirement to that instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 modify-instance-metadata-options \
  --region eu-west-1 \
  --instance-id i-0123456789abcdef0 \
  --http-tokens required \
  --no-cli-pager

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/ec2/modify-instance-metadata-options.html" rel="noopener noreferrer"&gt;modify command&lt;/a&gt; can return &lt;code&gt;pending&lt;/code&gt; for a running instance. Verify that the setting reaches &lt;code&gt;applied&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;aws ec2 describe-instances \
  --region eu-west-1 \
  --instance-ids i-0123456789abcdef0 \
  --query &lt;span class="s1"&gt;'Reservations[].Instances[].MetadataOptions'&lt;/span&gt; \
  --output json \
  --no-cli-pager

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect &lt;code&gt;HttpTokens&lt;/code&gt; to be &lt;code&gt;required&lt;/code&gt; and &lt;code&gt;State&lt;/code&gt; to be &lt;code&gt;applied&lt;/code&gt;. The commands here are examples, not a transcript from a production account.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Do not pipe every inventory result straight into the modification command. First check application health and rejected tokenless requests on the staging instance. Keep any rollback scoped to the affected workload and record why it was necessary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Keep replacement instances from undoing the work
&lt;/h2&gt;

&lt;p&gt;Changing one running instance leaves a second question: what happens when Auto Scaling replaces it? Update the launch configuration your fleet actually uses, then launch a replacement in staging and inspect its effective metadata options.&lt;/p&gt;

&lt;p&gt;AWS's &lt;a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-options.html" rel="noopener noreferrer"&gt;configuration precedence rules&lt;/a&gt; put explicit launch settings ahead of account defaults, then AMI settings. Account defaults also do not rewrite existing instances. Review the launch template version your Auto Scaling group selects rather than assuming a newly edited template is already in use.&lt;/p&gt;

&lt;p&gt;For the rollout, keep two views side by side: configuration exceptions and application health. Use &lt;code&gt;MetadataNoToken&lt;/code&gt; during preparation and &lt;code&gt;MetadataNoTokenRejected&lt;/code&gt; after enforcement. Keep missing telemetry visibly unknown, and assign each remaining exception an owner.&lt;/p&gt;

&lt;p&gt;The useful finishing condition is specific: the intended instances require tokens, representative workloads still work, and replacement instances inherit the same setting. The new metric makes the configuration part easier to watch continuously.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/ec2-imdsv1-enabled-metric" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ec2</category>
      <category>security</category>
      <category>cloudwatch</category>
    </item>
    <item>
      <title>We Built Opsville, a Free Pixel-Art Town Where You Fix DevOps Incidents</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 09 Nov 2026 10:42:36 +0000</pubDate>
      <link>https://dev.to/devopsdaily/we-built-opsville-a-free-pixel-art-town-where-you-fix-devops-incidents-5892</link>
      <guid>https://dev.to/devopsdaily/we-built-opsville-a-free-pixel-art-town-where-you-fix-devops-incidents-5892</guid>
      <description>&lt;p&gt;Opsville is a small pixel-art town where every district is broken, and you are the one on call. 🏘️ Each district is one DevOps topic: Linux, Docker, Git, CI/CD, Kubernetes, monitoring, security and databases. The people there hand you quests. Most quests open one of our browser simulators, then ask three questions. Fix all eight districts and the town gets its Launch Day.&lt;/p&gt;

&lt;p&gt;It is free, it runs in your browser, and you can play it now at &lt;a href="https://devops-daily.com/opsville/" rel="noopener noreferrer"&gt;devops-daily.com/opsville&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devops-daily.com/opsville/" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6le8i8cg60rjlug0lrmm.png" alt="The Opsville title screen at night, in front of a pixel skyline of the town" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fga84yfisa4r9z8cds4sd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fga84yfisa4r9z8cds4sd.gif" alt="The first 7 seconds of the Opsville trailer: fireworks, the logo and the town" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;▶️ &lt;a href="https://devops-daily.com/images/posts/opsville-free-devops-town-game/trailer.mp4" rel="noopener noreferrer"&gt;Watch the full 30-second trailer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Disclosure: we build Opsville at DevOps Daily. It is free and needs no sign-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to play, in 60 seconds ⏱️
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the game and press &lt;strong&gt;Start&lt;/strong&gt;. Make a character, or skip that and change your look later at the Wardrobe.&lt;/li&gt;
&lt;li&gt;Move with the arrow keys or WASD. On a phone you get a joystick and a &lt;strong&gt;Talk&lt;/strong&gt; button. A gamepad works too.&lt;/li&gt;
&lt;li&gt;Walk up to Mayor Chris in the town square and press &lt;strong&gt;E&lt;/strong&gt;. He explains the plan: the town goes live soon, and no district works.&lt;/li&gt;
&lt;li&gt;Head west to Linux Lodge. Alex says the lodge server keeps saying "No space left on device". Take the quest, and the Linux terminal simulator opens in a panel (on a phone, in a new tab).&lt;/li&gt;
&lt;li&gt;Finish it, then answer three questions from our quiz bank. Two right answers pass. You get XP and a badge.&lt;/li&gt;
&lt;li&gt;Finish every quest in a district, and the next one opens. Clearing Linux Lodge also gets you a bike (press &lt;strong&gt;B&lt;/strong&gt;) and Pip, a penguin pet who follows you around.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Handy keys: &lt;strong&gt;Q&lt;/strong&gt; for quests, &lt;strong&gt;M&lt;/strong&gt; for the map, &lt;strong&gt;P&lt;/strong&gt; for your profile, &lt;strong&gt;H&lt;/strong&gt; for help, &lt;strong&gt;F&lt;/strong&gt; for photo mode, and &lt;strong&gt;1&lt;/strong&gt; to &lt;strong&gt;4&lt;/strong&gt; for emotes.&lt;/p&gt;

&lt;p&gt;There are 8 districts and 34 quests. Progress saves in your browser, so you can close the tab and come back later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three moments we like
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The boss fight is a live incident 🚨
&lt;/h3&gt;

&lt;p&gt;From Docker Docks on, each district ends with a boss, which opens after the quests before it. The boss quiz plays as an incident: a status board with three systems that go from Down to Fixed as you answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcj4qrrvi2lz29z7v9c3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcj4qrrvi2lz29z7v9c3.png" alt="A boss quiz in Docker Docks: the container runtime is fixed, the host kernel and the image registry are still down" width="636" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In Docker Docks the board shows the container runtime, the host kernel and the image registry. In Git Garden it is the main branch, the release tags and the team clones. In the Observatory it is checkout, the status page and the on-call channel. Get two of the three questions right and the incident closes. Sophia at the Docks sums it up: "Every container stays in its box. Incident closed."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The pager 📟
&lt;/h3&gt;

&lt;p&gt;Once you clear Linux Lodge, the pager starts. It stays quiet for the first three minutes, then pages you every 6 to 10 minutes, never while a panel is open. Each page comes from a district you have already opened:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disk full on the Lodge server!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Press &lt;strong&gt;On my way&lt;/strong&gt; and you have two minutes to get there and 30 seconds to fix it. You see the symptom (&lt;code&gt;df -h&lt;/code&gt; shows &lt;code&gt;/&lt;/code&gt; at 100%, and &lt;code&gt;/var/log/app.log&lt;/code&gt; is 38 GB and growing) and three possible fixes. Pick "Reboot the server" and the game tells you why that fails: the disk is still full after a reboot, and the log keeps growing. The right fix is to truncate the log and set up logrotate.&lt;/p&gt;

&lt;p&gt;There are ten pages across the eight districts, from a container stuck in a restart loop to an expired TLS certificate. If the pager gets too much, snooze it for 15 minutes or turn it off in Help.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The secret server room 🗄️
&lt;/h3&gt;

&lt;p&gt;Twelve lost packets are hidden around town, and each one has a short story. "A DNS query from 2009" is still waiting for an answer. "A retry with no backoff" tried 9,000 times a second and is tired now.&lt;/p&gt;

&lt;p&gt;Find all twelve, and an old hatch by the lake in the town square opens. Below it is a basement full of blinking racks and one terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ uptime
 09:00:00 up 3652 days,  1 user,  load average: 0.01, 0.01, 0.00
$ cat /var/log/packets.log
delivered: A lonely TCP SYN
delivered: A DNS query from 2009
...
$ echo "12 of 12 packets home"
12 of 12 packets home
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3rcpa8og75oxwpjr38ra.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3rcpa8og75oxwpjr38ra.png" alt="The secret server room under the town square, next to its terminal: opsville-prod-1, up 3652 days" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It is the oldest server in town, ten years without a reboot. Finding every packet also gets you the golden packet cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we built it 🛠️
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Vite, TypeScript and Phaser 3.&lt;/strong&gt; Phaser draws the world: the map, the people, the weather and the effects. The panels, quizzes and HUD are plain DOM on top of the canvas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maps from text.&lt;/strong&gt; The whole world is one text file of 72 x 50 characters, where one character is one 16 x 16 tile. A build script turns it into Tiled JSON with ground, decor, collision and object layers. Three rows from the top of the map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P..*.:::.......::::.*..P................ggggg...P..%%%%........%%%%....P
P.v........)..:....v...P................SSDSS...P..HHHH........HHHH.T..P
P..*..T.:::::::.*..T.*.P........[.........:.....P..HHHH....]...HHHH....P
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;P&lt;/code&gt; is a pine, &lt;code&gt;:&lt;/code&gt; a stone path, &lt;code&gt;g&lt;/code&gt; a grey roof, &lt;code&gt;S&lt;/code&gt; a stone wall, &lt;code&gt;D&lt;/code&gt; a door and &lt;code&gt;H&lt;/code&gt; a castle wall. Moving a building is a diff you can review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static files on Cloudflare Pages.&lt;/strong&gt; The game builds with the base path &lt;code&gt;/opsville/&lt;/code&gt;. A release script copies the build into our main site, and Cloudflare Pages deploys it with everything else. The game needs no server of its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;XP that the server decides.&lt;/strong&gt; Sign-in is optional, with a free DevOps Daily account. When you are signed in, the game only tells our learning platform's API "I finished quest X". The API looks the quest up in its own list and decides the XP. Each quest counts once per player, two quests must be at least 30 seconds apart, and one player can count at most 24 quests in 24 hours. A test in the game fails when its quest list drifts from the server's list, so the weekly leaderboard stays fair.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://devops-daily.com/posts/opsville-free-devops-town-game" rel="noopener noreferrer"&gt;launch post&lt;/a&gt; has the rest of the build story: how a simulator in a frame tells the game you finished, and how we made two art sources look like one game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Start in Linux Lodge and see how far you get before the pager goes off: &lt;a href="https://devops-daily.com/opsville/" rel="noopener noreferrer"&gt;play Opsville free&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you could add a ninth district, what would it be, and what would its boss incident look like? Tell us in the comments. 👇&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>gamedev</category>
      <category>devops</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Opsville: A Free Pixel-Art DevOps Town in Your Browser</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Mon, 09 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/opsville-a-free-pixel-art-devops-town-in-your-browser-3pne</link>
      <guid>https://dev.to/devopsdaily/opsville-a-free-pixel-art-devops-town-in-your-browser-3pne</guid>
      <description>&lt;p&gt;Opsville is a small pixel-art town where every district is broken, and you are the one on call. It is free, it runs in your browser, and it is live today at &lt;a href="https://devops-daily.com/opsville/" rel="noopener noreferrer"&gt;devops-daily.com/opsville&lt;/a&gt;. Most quests open one of the simulators from our &lt;a href="https://devops-daily.com/simulators" rel="noopener noreferrer"&gt;simulators page&lt;/a&gt;, then ask you three questions from our quiz bank. Fix all eight districts and the town gets its Launch Day.&lt;/p&gt;

&lt;p&gt;This post covers the game first, then how we built it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncsou1v8pf4qpg4i8j9a.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fncsou1v8pf4qpg4i8j9a.webp" alt="The Opsville title screen in front of a pixel skyline of the town's buildings" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A modern browser on a desktop or a phone.&lt;/li&gt;
&lt;li&gt;A keyboard, a gamepad or a touch screen.&lt;/li&gt;
&lt;li&gt;No account. Your progress saves in your browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Walk into town
&lt;/h2&gt;

&lt;p&gt;You start in the town square. Mayor Chris has a problem: the town goes live soon, and none of the districts work. That is the &lt;strong&gt;Launch Day&lt;/strong&gt; story. Each district is a chapter, with a closing scene once you fix it. The Agent, the robot from our book &lt;a href="https://devops-daily.com/books/devops-survival-guide" rel="noopener noreferrer"&gt;The DevOps Survival Guide&lt;/a&gt;, waits in the square and comments when you walk into a new district.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbgs4f80ptwrez9r91a5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbgs4f80ptwrez9r91a5.webp" alt="Mayor Chris announces Launch Day in the town square, next to the quest board, the wardrobe and the leaderboard" width="799" height="344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are 8 districts and 34 quests. The districts open one after another:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Linux Lodge&lt;/li&gt;
&lt;li&gt;Docker Docks&lt;/li&gt;
&lt;li&gt;Git Garden&lt;/li&gt;
&lt;li&gt;CI/CD Railway&lt;/li&gt;
&lt;li&gt;Kubernetes Castle&lt;/li&gt;
&lt;li&gt;Observatory&lt;/li&gt;
&lt;li&gt;Security Fort&lt;/li&gt;
&lt;li&gt;Neon Data Depot&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A broken district looks broken. Smoke rises from its landmark, its lamps flicker at night, and its people grumble. When you fix it, lights go up over its sign and its music opens up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0d1ntn1d14yakp3p40bq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0d1ntn1d14yakp3p40bq.webp" alt="Linux Lodge in falling snow, with the Agent's tip from the book: check the disk and the processes before you reboot" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How a quest works
&lt;/h2&gt;

&lt;p&gt;Walk up to someone and press E, or tap Talk on a phone. They give you a task, such as "The disk is full" in Linux Lodge. The quest opens a simulator from our site in a panel: the Linux terminal, the &lt;a href="https://devops-daily.com/games/fork-bomb-simulator" rel="noopener noreferrer"&gt;Fork Bomb Simulator&lt;/a&gt;, the &lt;a href="https://devops-daily.com/games/docker-escape" rel="noopener noreferrer"&gt;Container Security Challenge&lt;/a&gt;, the &lt;a href="https://devops-daily.com/games/k8s-scheduler" rel="noopener noreferrer"&gt;Kubernetes Scheduler Challenge&lt;/a&gt; and more. When you finish, you answer three questions from our quiz bank. Two right answers pass the quest.&lt;/p&gt;

&lt;p&gt;From Docker Docks on, each district ends with a boss. A boss quiz plays as a &lt;strong&gt;live incident&lt;/strong&gt; : a status board with three systems that go from "Down" to "Fixed" as you answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5rppm7kx58p2tq47nm19.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5rppm7kx58p2tq47nm19.webp" alt="A boss quiz as a live incident: the container runtime is fixed while the host kernel and the image registry are still down" width="644" height="696"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each quest gives you XP and a badge. Each cleared district gives you something new: a bike after Linux Lodge, a pet for each of the first five districts (a penguin, a little whale, a cat, a dog and a mini Agent), and new hats.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to do between quests
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pager.&lt;/strong&gt; After you fix Linux Lodge, an incident in an open district pages you now and then. You get two minutes to get there and 30 seconds to fix it. You can snooze it, or turn it off in Help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The daily quest.&lt;/strong&gt; One question a day from the quiz bank. Seven days in a row earns the Streak beanie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your house.&lt;/strong&gt; You get a log cottage in Linux Lodge with your name on the sign. Inside, there is a cup for every cleared district and a medal for every badge. The garden next to it grows a plant for each day you played in the last four weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The newsstand.&lt;/strong&gt; The latest DevOps Daily posts, in the town square.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photo mode.&lt;/strong&gt; Press F, pick a frame and a filter, and save a PNG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your look.&lt;/strong&gt; A character creator on first start, and a wardrobe in the square for later changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Advent board.&lt;/strong&gt; From 1 to 24 December, the town gets snow, lights and a board with one door a day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgd1y5z0q4cgn2d5ktuuv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgd1y5z0q4cgn2d5ktuuv.webp" alt="Your log cottage in Linux Lodge, with its name sign, mailbox and the streak garden beside it" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Twelve lost packets are hidden around town. Find all of them, and an old hatch by the lake opens. Below it is a server room that has not been rebooted in a long time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgism3y16jcovlrrbmv3r.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgism3y16jcovlrrbmv3r.webp" alt="The secret basement server room, with rows of blinking racks and a terminal labelled opsville-prod-1" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Accounts are optional
&lt;/h2&gt;

&lt;p&gt;You can play the whole game without an account. If you sign in with a free DevOps Daily account, your save follows you to other devices. You can also join the weekly leaderboard (opt-in, with a board name only), help fill a weekly town goal with everyone else's XP, and race district speedruns.&lt;/p&gt;

&lt;p&gt;The Help panel has a larger text switch. You can play with a keyboard, a gamepad or touch controls. If your system asks for reduced motion, the game turns off the moving effects. Sound is off until you turn it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we built it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Vite, TypeScript and Phaser 3
&lt;/h3&gt;

&lt;p&gt;Phaser draws the world: the map, the people, the weather and the effects. The panels, quizzes and HUD are plain HTML on top of the canvas.&lt;/p&gt;

&lt;h3&gt;
  
  
  The map is a text file
&lt;/h3&gt;

&lt;p&gt;The whole world is one text file: 72 x 50 characters, where one character is one 16 x 16 tile. A build script turns it into Tiled JSON with ground, decor, collision and object layers. Moving a building is a diff you can review. Here are four rows from the top of the map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P..*.:::.......::::.*..P................ggggg...P..%%%%........%%%%....P
P.v........)..:....v...P................SSDSS...P..HHHH........HHHH.T..P
P..*..T.:::::::.*..T.*.P........[.........:.....P..HHHH....]...HHHH....P
P.*.......:......*.....P########################P..........:...........P

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;P&lt;/code&gt; is a pine, &lt;code&gt;:&lt;/code&gt; a stone path, &lt;code&gt;#&lt;/code&gt; a rail track, &lt;code&gt;g&lt;/code&gt; a grey roof, &lt;code&gt;S&lt;/code&gt; a stone wall and &lt;code&gt;D&lt;/code&gt; a door. Characters such as &lt;code&gt;[&lt;/code&gt; and &lt;code&gt;)&lt;/code&gt; are objects, defined in a list below the grid.&lt;/p&gt;

&lt;h3&gt;
  
  
  Static files inside the main site
&lt;/h3&gt;

&lt;p&gt;The game builds with the base path &lt;code&gt;/opsville/&lt;/code&gt;. A release script copies the build into the site's repo, adds the game's response headers, and opens one pull request. Cloudflare Pages deploys it with the rest of the static site. The game needs no server of its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  Simulators in a frame
&lt;/h3&gt;

&lt;p&gt;A quest loads &lt;code&gt;/games/&amp;lt;slug&amp;gt;/embed?embed=opsville&amp;amp;run=&amp;lt;id&amp;gt;&lt;/code&gt; in a frame. When you finish, the simulator sends a message on the same origin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Sent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;by&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;simulator&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;when&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;player&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;finishes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;it&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;type:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'devops-daily:game-complete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;game:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&amp;lt;slug&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;run:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&amp;lt;id&amp;gt;'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On phones the simulator opens in a new tab, and the same message goes over a &lt;code&gt;BroadcastChannel&lt;/code&gt;. If no message arrives, a "Done, take the quiz" button covers it. Either way, the quiz is the real gate.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Quest-giver&lt;/strong&gt; talk to a local&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulator&lt;/strong&gt; /games embed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;game-complete&lt;/strong&gt; postMessage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quiz gate&lt;/strong&gt; 2 of 3 to pass&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XP and badge&lt;/strong&gt; server-checked&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  XP that the server decides
&lt;/h3&gt;

&lt;p&gt;When you are signed in, the game only says "I finished quest X". The learning platform's API looks the quest up in its own list and decides the XP. Each quest counts once per player, two quests must be at least 30 seconds apart, and a player can count at most 24 quests in 24 hours. The rows live in the learning platform's existing Neon database, so the game did not need a new one. A test in the game fails when its quest list drifts from the server's list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Art from two sources
&lt;/h3&gt;

&lt;p&gt;The tiles and characters come from &lt;a href="https://www.kenney.nl" rel="noopener noreferrer"&gt;Kenney's&lt;/a&gt; CC0 packs: Tiny Town, Tiny Dungeon and RPG Urban Pack. The landmarks, signs and portraits are generated pixel art. A Python script cuts out their background, scales them to the pixel grid, and maps every colour to one warm palette shared with the tiles. That way both sources look like one game. The music is ours too: chords and patterns played with WebAudio, with no sound files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Play it and tell us what breaks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Play Opsville free at &lt;a href="https://devops-daily.com/opsville/" rel="noopener noreferrer"&gt;devops-daily.com/opsville&lt;/a&gt;.&lt;/strong&gt; Start in Linux Lodge, and see how far you get before the pager goes off.&lt;/p&gt;

&lt;p&gt;Found a bug, or have an idea for a quest or a district? &lt;a href="https://github.com/The-DevOps-Daily/devops-daily/issues/new/choose" rel="noopener noreferrer"&gt;Open an issue on our GitHub repo&lt;/a&gt; and tell us.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/opsville-free-devops-town-game" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>games</category>
      <category>learning</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Build an Everyday AI Assistant with DigitalOcean Inference</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Fri, 06 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/build-an-everyday-ai-assistant-with-digitalocean-inference-4e04</link>
      <guid>https://dev.to/devopsdaily/build-an-everyday-ai-assistant-with-digitalocean-inference-4e04</guid>
      <description>&lt;p&gt;You finish work with groceries to buy, a kitchen to clean, and a workout you meant to do. A friend wants to meet, too. Writing those things into a to-do list takes a minute. Deciding what fits into an evening when you are already tired is the harder part.&lt;/p&gt;

&lt;p&gt;That is the problem we are building for. &lt;strong&gt;Daylight&lt;/strong&gt; is a small personal AI assistant that helps you make tradeoffs, proposes a manageable plan, and adapts when your circumstances change. Work is one part of the picture; dinner, hobbies, friends, chores, and rest belong in the same conversation.&lt;/p&gt;

&lt;p&gt;You can run the app locally with Python and a DigitalOcean Inference key. After getting it working, we will look at how a model proposes structured changes without getting direct control of your saved data. The same request, validation, and persistence pattern is useful in plenty of internal tools.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Python 3.11 or newer, Git, and a terminal with &lt;code&gt;make&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A DigitalOcean account with &lt;a href="https://docs.digitalocean.com/products/inference/how-to/si-overview/" rel="noopener noreferrer"&gt;Serverless Inference billing configured&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A model access key scoped to a text model with function calling support.&lt;/li&gt;
&lt;li&gt;Node.js 22 or newer only if you want to run the optional browser-rendering tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The example uses &lt;code&gt;mimo-v2.5-pro&lt;/code&gt;. DigitalOcean lists Chat Completions, tool calling, and structured outputs for it in the &lt;a href="https://docs.digitalocean.com/products/inference/details/models/" rel="noopener noreferrer"&gt;model catalog&lt;/a&gt;. Availability and supported parameters vary by model, so check the catalog when selecting another one.&lt;/p&gt;

&lt;p&gt;Each chat submission makes a billable inference request. Completing tasks, accepting plans, deleting tasks, and starting a new day are local application actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Daylight running
&lt;/h2&gt;

&lt;p&gt;The complete app is in the companion repository:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/digitalocean-inference-daylight" rel="noopener noreferrer"&gt;The-DevOps-Daily/digitalocean-inference-daylight on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Clone it, install the Python dependencies, and copy the configuration template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/The-DevOps-Daily/digitalocean-inference-daylight.git
&lt;span class="nb"&gt;cd &lt;/span&gt;digitalocean-inference-daylight
make &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no frontend build or separate database service. FastAPI serves the browser interface and API; Python's built-in SQLite support handles persistence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create a model access key
&lt;/h3&gt;

&lt;p&gt;Open DigitalOcean's &lt;a href="https://cloud.digitalocean.com/model-studio/manage-keys" rel="noopener noreferrer"&gt;Model Access Keys page&lt;/a&gt; and create a key for Daylight. Select the model you intend to use. For a request from your laptop, choose &lt;strong&gt;No VPC network&lt;/strong&gt; ; a VPC-restricted key requires requests from that network. Copy the secret when it is shown.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.digitalocean.com/products/inference/how-to/manage-model-access-keys/" rel="noopener noreferrer"&gt;key-management documentation&lt;/a&gt; explains model scoping and VPC restrictions. Use a key dedicated to this app rather than sharing credentials between experiments.&lt;/p&gt;

&lt;p&gt;Set these values in &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;DIGITALOCEAN_INFERENCE_KEY&lt;/span&gt;=&lt;span class="n"&gt;your&lt;/span&gt;-&lt;span class="n"&gt;model&lt;/span&gt;-&lt;span class="n"&gt;access&lt;/span&gt;-&lt;span class="n"&gt;key&lt;/span&gt;
&lt;span class="n"&gt;DIGITALOCEAN_INFERENCE_MODEL&lt;/span&gt;=&lt;span class="n"&gt;mimo&lt;/span&gt;-&lt;span class="n"&gt;v2&lt;/span&gt;.&lt;span class="m"&gt;5&lt;/span&gt;-&lt;span class="n"&gt;pro&lt;/span&gt;
&lt;span class="n"&gt;DAYLIGHT_DATABASE&lt;/span&gt;=&lt;span class="n"&gt;data&lt;/span&gt;/&lt;span class="n"&gt;daylight&lt;/span&gt;.&lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="n"&gt;DIGITALOCEAN_REASONING_EFFORT&lt;/span&gt;=&lt;span class="n"&gt;none&lt;/span&gt;
&lt;span class="n"&gt;INFERENCE_TIMEOUT_SECONDS&lt;/span&gt;=&lt;span class="m"&gt;120&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DIGITALOCEAN_REASONING_EFFORT&lt;/code&gt; is optional. Remove it if your selected model does not support that setting. DigitalOcean documents reasoning controls in &lt;a href="https://docs.digitalocean.com/products/inference/how-to/use-reasoning/" rel="noopener noreferrer"&gt;Using Reasoning with the Chat Completions and Responses API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Start the server:&lt;br&gt;
&lt;/p&gt;

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

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;a href="http://127.0.0.1:8082" rel="noopener noreferrer"&gt;http://127.0.0.1:8082&lt;/a&gt;. Keep the terminal running while you use the app. After changing &lt;code&gt;.env&lt;/code&gt;, stop the server with &lt;code&gt;Ctrl+C&lt;/code&gt; and run &lt;code&gt;make run&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;The access key stays in the backend process. The browser sends requests to your local FastAPI server, which calls DigitalOcean's &lt;a href="https://docs.digitalocean.com/products/inference/how-to/si-endpoints/" rel="noopener noreferrer"&gt;Chat Completions endpoint&lt;/a&gt; at &lt;code&gt;https://inference.do-ai.run/v1/chat/completions&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You control the application code and its local database. Model inference is hosted: chat submissions send recent conversation, your saved plan, preferences, pending proposals, and recently removed task IDs and titles to DigitalOcean and the selected model service. This is not an offline assistant.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Give it a day that needs a little rearranging
&lt;/h2&gt;

&lt;p&gt;Set the available time to &lt;strong&gt;90 minutes&lt;/strong&gt; and energy to &lt;strong&gt;Running low&lt;/strong&gt;. Then try:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I need groceries for dinner, the kitchen needs cleaning, and I wanted to exercise. A friend also wants to meet tonight. Nothing has a hard deadline except eating dinner. I prefer relaxed evenings. Help me make a realistic plan.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A useful response does more than repeat your list. It considers which activities can shrink, what depends on something else, and what can wait. It might suggest a focused grocery trip, a simple meal, a short kitchen reset, and lighter movement instead of a full workout. It should explain the tradeoffs so you can disagree with them.&lt;/p&gt;

&lt;p&gt;In our live test, the first proposal allocated 85 minutes to groceries, dinner, cleanup, and a short walk or stretch. It deferred the full workout and meeting a friend. That is one possible answer, not the correct answer for everyone's evening. If seeing your friend matters more, tell the assistant that and revise the proposal.&lt;/p&gt;

&lt;p&gt;The right-hand panel labels the result &lt;strong&gt;Proposed changes&lt;/strong&gt;. Select &lt;strong&gt;This works for me&lt;/strong&gt; to save it. Until then, the accepted plan remains unchanged.&lt;/p&gt;

&lt;p&gt;Now mark groceries complete, change the time control to &lt;strong&gt;20 minutes&lt;/strong&gt; , and send:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The groceries are done. My evening changed and I only have 20 minutes now. Keep dinner as the priority. Suggest a simple meal and defer the cleanup and exercise if necessary. Preserve everything else, including the completed grocery task.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In our test, Daylight reduced dinner to 20 minutes and moved the remaining activities to Later. Groceries stayed completed. Accepting the revision and reloading preserved that state.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn91lzsyncwch0k2vvxga.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn91lzsyncwch0k2vvxga.png" alt="Daylight showing a saved 20-minute dinner plan alongside the conversation explaining the tradeoffs" width="800" height="956"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshot recreates the 20-minute dinner scenario above. Your model's wording and task estimates will vary.&lt;/p&gt;

&lt;p&gt;This is the contribution we want from the AI: help someone decide what is doable when time, energy, and priorities collide. The task list gives that reasoning somewhere to land.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the assistant proposes changes
&lt;/h2&gt;

&lt;p&gt;The implementation is small enough to follow without an agent framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
├── agent.py # Prompt, inference request, response validation
├── config.py # Inference timeout validation
├── main.py # API routes and user actions
├── models.py # Request and plan schemas
├── store.py # SQLite state and revision checks
└── static/ # HTML, CSS, JavaScript, local Markdown renderer

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each chat submission includes the current date from the browser, available minutes, energy, saved plan, pending proposal, removed tasks, and the latest 16 conversation messages. Accepted preferences live inside the saved plan rather than depending on an ever-growing transcript.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your situation&lt;/strong&gt; message, time, energy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FastAPI + context&lt;/strong&gt; saved plan and history&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DO Inference&lt;/strong&gt; question or tool arguments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validated proposal&lt;/strong&gt; review the tradeoffs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept and save&lt;/strong&gt; SQLite transaction&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If an important detail is missing, the model can ask a clarifying question instead of proposing a plan. Otherwise, it can return a &lt;strong&gt;function tool call&lt;/strong&gt; named &lt;code&gt;propose_plan&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a constrained planning assistant, not a background worker that runs arbitrary tools. There is one model request per submitted message. The tool call is a structured proposal; the app does not dispatch model-generated function names to Python functions or let the model write to SQLite.&lt;/p&gt;

&lt;h3&gt;
  
  
  Define what a plan contains
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;app/models.py&lt;/code&gt;, a task has a stable ID, title, time estimate, group, and reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StrictModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^[A-Za-z0-9_-]{1,80}$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;today&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;later&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full plan also contains a headline, explanation, task array, and preferences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StrictModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;headline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;explanation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StrictModel&lt;/code&gt; rejects unknown fields and strips surrounding whitespace. The &lt;code&gt;memory&lt;/code&gt; field is a visible note of preferences you have explicitly shared, such as preferring relaxed evenings. The prompt tells the model to preserve those preferences unless corrected and to avoid inventing sensitive traits. You review proposed preferences with the plan before acceptance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Put that schema in the inference request
&lt;/h3&gt;

&lt;p&gt;The request in &lt;code&gt;app/agent.py&lt;/code&gt; uses the Pydantic schema as the function's parameter definition. This excerpt shows the relevant fields; the full implementation also handles configuration and provider errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DIGITALOCEAN_INFERENCE_MODEL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mimo-v2.5-pro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;propose_plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Propose a complete realistic plan for user review; does not save it.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_json_schema&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_choice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&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 model sees the request's context and decides whether to ask a question or supply tool arguments. It does not see an API key in the message content; &lt;code&gt;httpx&lt;/code&gt; adds authentication as a server-side authorization header.&lt;/p&gt;

&lt;h3&gt;
  
  
  Validate the proposal before showing an accept button
&lt;/h3&gt;

&lt;p&gt;A function schema tells the model what to produce. The server still needs to check what it actually returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;proposal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tasks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Duplicate task IDs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;planned_minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minutes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tasks&lt;/span&gt;&lt;span class="sh"&gt;"&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;task&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;today&lt;/span&gt;&lt;span class="sh"&gt;"&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;planned_minutes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Plan exceeds available time&lt;/span&gt;&lt;span class="sh"&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 code also checks that saved commitments are not silently omitted, completed tasks remain completed, and deleted task IDs do not reappear. If validation fails, it rejects the response without replacing the saved plan.&lt;/p&gt;

&lt;p&gt;Accepting arbitrary JSON because the HTTP request returned 200 would skip all of those checks. Passing the arguments through Pydantic is the first step; checking the application's own rules is the second.&lt;/p&gt;

&lt;p&gt;These checks do not prove that grocery shopping really takes 25 minutes, that a suggested dinner suits you, or that two tasks represent the same commitment under different IDs. You still review the proposal. The prompt also tells the assistant not to claim it checked a calendar, contacted your friend, or scheduled a reminder: none of those integrations exists in this app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep control of the plan between conversations
&lt;/h2&gt;

&lt;p&gt;The accepted plan stays in &lt;code&gt;data/daylight.sqlite3&lt;/code&gt; across page reloads, browser sessions, and server restarts. It has no expiry date and does not reset at midnight.&lt;/p&gt;

&lt;p&gt;The usual task actions are ordinary application behavior:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Complete a task&lt;/td&gt;
&lt;td&gt;Moves it to Done and invalidates an older pending proposal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reopen a task&lt;/td&gt;
&lt;td&gt;Restores its previous Today or Later group.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete a task&lt;/td&gt;
&lt;td&gt;Removes it immediately; the previous plan is available through Undo task deletion until another plan change replaces that snapshot.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accept a proposal&lt;/td&gt;
&lt;td&gt;Saves the reviewed replacement plan and keeps one previous plan for undo.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dismiss a proposal&lt;/td&gt;
&lt;td&gt;Keeps the accepted plan.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A trash button appears beside each saved task's time estimate. Proposed tasks must be accepted before those controls become active.&lt;/p&gt;

&lt;p&gt;Deleting a task also adds its ID and title to bounded removed-task context, so the assistant is told not to recreate it from an old conversation. Reusing the deleted ID is rejected. This is not semantic duplicate detection: a model could still invent a different ID for the same activity, so inspect the next proposal. You can explicitly ask to add an activity again with a new ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clear the conversation or start a new day
&lt;/h3&gt;

&lt;p&gt;Both actions are in the &lt;strong&gt;Chat options&lt;/strong&gt; menu at the top-right of the conversation panel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clear conversation&lt;/strong&gt; removes the chat and pending proposal while keeping the accepted plan, task progress, and preferences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start a new day&lt;/strong&gt; clears the chat, removes completed tasks from the current plan, and carries unfinished tasks into Later for review. Preferences remain saved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both clear the old undo snapshot and ask for confirmation. Starting a new day prepares an editable message; it does not submit an inference request until you press Send. Check your time and energy controls before sending it.&lt;/p&gt;

&lt;p&gt;Saved chat is bounded to 60 messages. Inference reads only the latest 16, together with the explicit plan context. That makes the accepted plan the durable record of commitments, rather than hoping that a model can recover them from an unlimited chat log.&lt;/p&gt;

&lt;p&gt;SQLite also protects against a less visible problem: stale updates. Every state change increments a revision. A transaction reads the current revision under &lt;code&gt;BEGIN IMMEDIATE&lt;/code&gt;, compares it with the request's revision, applies the change, and commits. If a task changes or you clear the conversation while an inference request is running, the old response cannot overwrite the newer state. The API returns a conflict instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the behavior, then make it your own
&lt;/h2&gt;

&lt;p&gt;Run the backend checks and optional Markdown checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make check
node &lt;span class="nt"&gt;--test&lt;/span&gt; tests/markdown.test.cjs

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reviewed version has 39 backend tests and four Markdown tests. They cover plan validation, saved state, completion, deletion and undo, new-day behavior, stale updates, provider failures, host validation, and rendering untrusted model text. They use mocked inference responses and do not incur model charges. The repository also includes GitHub Actions checks for Python 3.11 and 3.14.&lt;/p&gt;

&lt;p&gt;Live testing answers a different question. Try your own mix of work and personal activities, accept a plan, reduce the time available, and check that the new proposal both fits the budget and preserves unfinished commitments. Reload after completing an item to verify persistence. A passing schema test alone cannot tell you whether the advice is useful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expect a few operational details
&lt;/h3&gt;

&lt;p&gt;We observed planning requests near a minute with MiMo V2.5 Pro, including one diagnostic at 58.7 seconds. Earlier requests exceeded the original 60-second timeout. That is a development observation, not a benchmark or a latency promise. The app now defaults to a configurable 120-second response timeout and a separate 10-second connection timeout. On timeout, it keeps the saved plan and leaves your message in the composer.&lt;/p&gt;

&lt;p&gt;If a request fails, identify which layer failed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;First thing to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local server offline / Failed to fetch&lt;/td&gt;
&lt;td&gt;Is &lt;code&gt;make run&lt;/code&gt; still running? Start it, then select Reconnect.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 401 from DigitalOcean&lt;/td&gt;
&lt;td&gt;The key may be invalid or revoked. Replace it in &lt;code&gt;.env&lt;/code&gt; and restart.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 403&lt;/td&gt;
&lt;td&gt;Check model access and any VPC restriction on the key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 429&lt;/td&gt;
&lt;td&gt;Wait before retrying; check the account's rate limits.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid plan&lt;/td&gt;
&lt;td&gt;The model returned malformed data or broke an application rule. Clarify the request and retry.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State conflict&lt;/td&gt;
&lt;td&gt;Another action changed the plan while the request was running. Review the latest state before resubmitting.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reconnect reloads local state; it does not resend a potentially billable request. Assistant Markdown is rendered with a locally bundled parser, with raw HTML and remote images disabled. User messages remain plain text, and provider error bodies are not echoed into the interface.&lt;/p&gt;

&lt;p&gt;Keep this first version on localhost. It has no authentication or separation between users. A shared deployment needs those features, a deliberate host policy, and durable database storage. Notifications, calendars, and unattended actions are separate projects, not hidden capabilities of the model.&lt;/p&gt;

&lt;p&gt;A useful next experiment is a small set of scenarios you can rerun when changing the prompt or model: low energy, an urgent errand, a missed day, a completed task, and a new activity you genuinely want to make room for. Judge whether the assistant makes sensible tradeoffs as well as whether its JSON passes validation. If you want a fuller evaluation workflow, our &lt;a href="https://dev.to/devopsdaily/build-and-evaluate-an-ai-error-explainer-with-digitalocean-inference-8l"&gt;DigitalOcean Inference error-explainer guide&lt;/a&gt; walks through one.&lt;/p&gt;

&lt;p&gt;You now have a local app whose AI helps turn competing intentions into a plan you can inspect and change. Try it with a real evening, tell it when its priorities are wrong, and see whether the revised day feels more doable.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/build-personal-ai-assistant-digitalocean-inference" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>digitalocean</category>
      <category>aiagents</category>
      <category>inference</category>
    </item>
    <item>
      <title>cgroup v2 memory.high Throttles, memory.max Kills: Measured on a Host Without Swap</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Fri, 06 Nov 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/cgroup-v2-memoryhigh-throttles-memorymax-kills-measured-on-a-host-without-swap-38m1</link>
      <guid>https://dev.to/devopsdaily/cgroup-v2-memoryhigh-throttles-memorymax-kills-measured-on-a-host-without-swap-38m1</guid>
      <description>&lt;p&gt;We ran one Python workload that allocates memory in 256 KiB steps, on a Linux 6.8 host with no swap, under cgroup v2 &lt;code&gt;memory.max&lt;/code&gt;, &lt;code&gt;memory.high&lt;/code&gt;, both and neither. When it asked for 320 MiB with only &lt;code&gt;memory.max&lt;/code&gt; at 256 MiB, it ran at full speed and the kernel killed it 0.28 to 0.39 seconds after it started. With only &lt;code&gt;memory.high&lt;/code&gt; at 230.4 MiB, the kernel did not kill it. It made new allocations wait instead, and after 150 seconds the workload held 11 to 12 MiB more than &lt;code&gt;memory.high&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The case to worry about is the smaller one. A version of the workload that needs 239 MiB fits under &lt;code&gt;memory.max&lt;/code&gt; and runs at full speed there. With &lt;code&gt;memory.high&lt;/code&gt; at 90% of that limit, which is what Kubernetes MemoryQoS writes for a 256Mi limit and a zero memory request with the 1.36 default factor, its median allocation took 191.8 ms on a CPU of its own and 6.2 seconds on a CPU it shared with our 100 ms sampler, against 0.26 ms with no limit. It reported no error.&lt;/p&gt;

&lt;p&gt;The kernel's throttle formula predicted the length of each sleep to within about 10%. The number of sleeps is what changed: once per 64-page batch, as the code suggests, or once per 4 KiB page. We found in review that our own sampler, on the same CPU, kept the task at once per page. Without it, the count still switched to once per page in phases, while other work ran on the host. And one condition changed both limits: when the program's own code was charged to its cgroup, the kernel reclaimed that code and read it back from disk over and over, and &lt;code&gt;memory.max&lt;/code&gt; did not kill it within the 20 seconds we allowed.&lt;/p&gt;

&lt;p&gt;This post has the numbers, the kernel formula, the method problems we found on the way, and a check of what systemd, Docker and Kubernetes write to these files.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;memory.max&lt;/code&gt; alone killed at full speed.&lt;/strong&gt; The 320 MiB workload ran at normal latency (p99 of the last 10% of operations 0.29 to 0.41 ms) and the kernel OOM-killed it 0.28 to 0.39 seconds after it started, in 3 of 3 runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;memory.high&lt;/code&gt; alone did not kill within the 150 second cap. It made new allocations wait.&lt;/strong&gt; Each sleep was 1.06 to 1.10 times the kernel's formula at the median, &lt;code&gt;64000 x o^2&lt;/code&gt; ms with &lt;code&gt;o&lt;/code&gt; the share over &lt;code&gt;memory.high&lt;/code&gt;. At the 2 s cap the median sleep was 2,047.9 ms, which fits the timer wheel's 64 ms step for a 2,000 ms timeout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The number of sleeps decides the cost, and a neighbour on the same CPU decides the number.&lt;/strong&gt; The kernel's comment says one sleep per 64-page batch. With our sampler on the same CPU, the task slept once per 4 KiB page in 95% of the operations whose sleep was 50 ms or more. Without that neighbour (the workload on another CPU, or the sampler once a second), it switched between about 1 and 64 sleeps per batch in phases of tens of seconds, and spent 46% to 72% of that time in the slow phases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A program that fits under &lt;code&gt;memory.max&lt;/code&gt; but not under &lt;code&gt;memory.high&lt;/code&gt; ran much slower and reported nothing.&lt;/strong&gt; With &lt;code&gt;memory.high&lt;/code&gt; at 90% of a 256 MiB limit (the kubelet's value for a zero memory request and factor 0.9), the median allocation took 191.8 ms on its own CPU and 6,216 ms next to the busy sampler (15 to 17 of its 200 churn operations in 150 s), against 0.257 ms with no limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both limits only delayed the kill.&lt;/strong&gt; With &lt;code&gt;memory.high&lt;/code&gt; at 90% of &lt;code&gt;memory.max&lt;/code&gt;, a leak was OOM-killed after 29.1 minutes next to the busy sampler. With &lt;code&gt;memory.max&lt;/code&gt; alone the kill took 0.3 seconds. On a CPU of its own, it was not killed within the 120 second cap in 3 of 3 runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code pages in the cgroup turn both limits into thrashing.&lt;/strong&gt; When the program's own binary was charged to its cgroup, the kernel reclaimed it and read it back from disk, 2.4 to 2.6 GB in 25 seconds at our 100 MiB/s disk cap. &lt;code&gt;memory.max&lt;/code&gt; did not kill it within 20 seconds in 3 of 3 runs, and memory PSI showed only 8% while io PSI showed 85%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page cache is not throttled or killed.&lt;/strong&gt; Neither limit killed the file-reading runs or put them to sleep. They capped the cache, so later reads went to disk more often (p99 1.0 to 2.6 ms against 0.13 ms with no limit).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tools:&lt;/strong&gt; systemd writes &lt;code&gt;MemoryHigh=&lt;/code&gt; and &lt;code&gt;MemoryMax=&lt;/code&gt; to &lt;code&gt;memory.high&lt;/code&gt; and &lt;code&gt;memory.max&lt;/code&gt; as given. Docker has no flag for &lt;code&gt;memory.high&lt;/code&gt;, and on cgroup v2 &lt;code&gt;--memory-reservation&lt;/code&gt; becomes &lt;code&gt;memory.low&lt;/code&gt;. Kubernetes MemoryQoS writes &lt;code&gt;memory.high&lt;/code&gt;; it is alpha and off in 1.36, and beta and on in 1.37 but with no throttling factor set, so no &lt;code&gt;memory.high&lt;/code&gt; unless you set one.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A Linux machine with cgroup v2 (&lt;code&gt;stat -fc %T /sys/fs/cgroup&lt;/code&gt; prints &lt;code&gt;cgroup2fs&lt;/code&gt;) and systemd&lt;/li&gt;
&lt;li&gt;Root, or a cgroup subtree delegated to your user&lt;/li&gt;
&lt;li&gt;Python 3 for the workload and the analysis scripts&lt;/li&gt;
&lt;li&gt;Check for swap first: &lt;code&gt;swapon --show&lt;/code&gt;. Our results are for a host with no swap. With swap, the kernel can move anonymous memory out, and both limits behave differently&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two limits, two code paths
&lt;/h2&gt;

&lt;p&gt;Both files live in a cgroup v2 directory. Both act when the kernel charges a new page to the cgroup. They do different things when the cgroup is over its value.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;memory.max&lt;/code&gt;&lt;/strong&gt; is the hard limit. The kernel docs for 6.8 say: "If a cgroup's memory usage reaches this limit and can't be reduced, the OOM killer is invoked in the cgroup." The charge path tries to reclaim first, retries, and calls the OOM killer only when reclaim makes no progress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;memory.high&lt;/code&gt;&lt;/strong&gt; is the throttle. The same docs say: "Going over the high limit never invokes the OOM killer and under extreme conditions the limit may be breached." The charge always succeeds. On the way back to user space, &lt;code&gt;mem_cgroup_handle_over_high()&lt;/code&gt; tries to reclaim, and if usage is still over the value it puts the task to sleep.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sleep length comes from &lt;code&gt;calculate_high_delay()&lt;/code&gt; in &lt;code&gt;mm/memcontrol.c&lt;/code&gt;. On the Ubuntu 6.8.0-147 kernel we ran, with &lt;code&gt;HZ=1000&lt;/code&gt;, it works out to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;o = (usage - memory.high) / memory.high
penalty = 64000 * o^2 ms per charge batch of 64 pages (MEMCG_CHARGE_BATCH)
          no sleep if penalty &amp;lt;= 10 ms; at most 2000 ms (MEMCG_MAX_HIGH_DELAY_JIFFIES = 2 * HZ)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the sleep is 0 until usage is 1.25% over &lt;code&gt;memory.high&lt;/code&gt;, 160 ms at 5% over, 640 ms at 10% over, and it hits the 2 s cap at 17.7% over. The comment above the cap reads "Clamp the maximum sleep time per allocation batch to 2 seconds." Each &lt;code&gt;reclaim_high()&lt;/code&gt; call adds 1 to the &lt;code&gt;high&lt;/code&gt; counter in &lt;code&gt;memory.events&lt;/code&gt;. When reclaim frees nothing, the kernel calls it 17 times before it sleeps (once, then &lt;code&gt;MAX_RECLAIM_RETRIES&lt;/code&gt; = 16 retries). When reclaim frees something, it keeps retrying and does not sleep. So in a cgroup with nothing to reclaim, the counter rises by 17 per sleep, and that is how we count sleeps from outside the kernel.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;page fault&lt;/strong&gt; charge to the cgroup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;over memory.max&lt;/strong&gt; before the charge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;over memory.high&lt;/strong&gt; after the charge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reclaim&lt;/strong&gt; direct, in the fault&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;reclaim_high x17&lt;/strong&gt; +17 to memory.events high&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OOM kill&lt;/strong&gt; nothing reclaimable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sleep&lt;/strong&gt; 64000 x o^2 ms, at most 2 s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;thrash&lt;/strong&gt; reclaim frees the task's own code&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Connections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;page fault -&amp;gt; over memory.max&lt;/li&gt;
&lt;li&gt;page fault -&amp;gt; over memory.high&lt;/li&gt;
&lt;li&gt;over memory.max -&amp;gt; reclaim&lt;/li&gt;
&lt;li&gt;over memory.high -&amp;gt; reclaim_high x17&lt;/li&gt;
&lt;li&gt;reclaim -&amp;gt; OOM kill (freed nothing)&lt;/li&gt;
&lt;li&gt;reclaim -&amp;gt; thrash (freed file pages)&lt;/li&gt;
&lt;li&gt;reclaim_high x17 -&amp;gt; sleep (freed nothing)&lt;/li&gt;
&lt;li&gt;reclaim_high x17 -&amp;gt; thrash (freed file pages)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without swap, the kernel has nowhere to put anonymous memory: heaps, stacks, anything a process allocated and wrote. In our runs that memory was never reclaimed (&lt;code&gt;pgsteal&lt;/code&gt; stayed 0 in every warm anonymous run). What reclaim could take were file pages: page cache and the mapped code of the program in the cgroup. That decides most of what follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab
&lt;/h2&gt;

&lt;p&gt;One host: Ubuntu 24.04, kernel 6.8.0-147-generic, systemd 255, 4 vCPU, 7.75 GiB of RAM, no swap device and zswap off, &lt;code&gt;CONFIG_HZ=1000&lt;/code&gt;, transparent huge pages on &lt;code&gt;madvise&lt;/code&gt; (&lt;code&gt;results/00-host.txt&lt;/code&gt;). It is a shared development machine. So every run happened inside one transient systemd scope with &lt;code&gt;Delegate=yes&lt;/code&gt; and &lt;code&gt;MemoryMax=1G&lt;/code&gt;, every run got its own child cgroup, and no limit went above 1 GiB.&lt;/p&gt;

&lt;p&gt;The workload is one script, &lt;code&gt;scripts/lib/workload.py&lt;/code&gt;. Each operation maps a 256 KiB private anonymous chunk and writes all of it. That is 64 pages, which is also the kernel's charge batch. Each operation also copies one random chunk it already holds, so the working set stays in use. The phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grow&lt;/strong&gt; until the workload holds its target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Churn&lt;/strong&gt; 200 times: free one random chunk and allocate a new one, so usage stays flat while the program keeps allocating, like a busy service.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The random choices are seeded (mulberry32). Before and after every operation, the workload reads &lt;code&gt;memory.current&lt;/code&gt; and the &lt;code&gt;high&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; counters in &lt;code&gt;memory.events&lt;/code&gt; from open file descriptors, and writes one JSON line with the latency. A driver outside the cgroup samples &lt;code&gt;memory.current&lt;/code&gt;, &lt;code&gt;memory.events&lt;/code&gt;, &lt;code&gt;memory.pressure&lt;/code&gt;, &lt;code&gt;io.pressure&lt;/code&gt;, &lt;code&gt;memory.stat&lt;/code&gt;, &lt;code&gt;cpu.stat&lt;/code&gt; and each process's &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/wchan&lt;/code&gt; and &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/stat&lt;/code&gt; every 100 ms. The driver pinned itself to CPU 0, and the workload inherited that, so in the main matrix the two shared one CPU. We only saw that in review, and it changed one of the results a lot. The section on how many sleeps a task pays has the runs that measure it.&lt;/p&gt;

&lt;p&gt;The two limits are fixed for every run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;memory.max&lt;/code&gt; = 268435456 bytes (256 MiB)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;memory.high&lt;/code&gt; = 241590272 bytes (230.4 MiB). That is the value the kubelet computes with MemoryQoS for &lt;code&gt;limits.memory: 256Mi&lt;/code&gt;, &lt;code&gt;requests.memory: 0&lt;/code&gt; and the 1.36 default throttling factor of 0.9. It is the lowest &lt;code&gt;memory.high&lt;/code&gt; that formula gives for a 256Mi limit. More on that below.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;What it shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-fit-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;grows to 232 MiB (about 239 MiB in &lt;code&gt;memory.current&lt;/code&gt;), then churns&lt;/td&gt;
&lt;td&gt;a program that fits under &lt;code&gt;memory.max&lt;/code&gt; but not under &lt;code&gt;memory.high&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-over-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;asks for 320 MiB&lt;/td&gt;
&lt;td&gt;a program that needs more than the limit, like a leak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-over-4p-high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same 320 MiB split over 4 processes&lt;/td&gt;
&lt;td&gt;whether the throttle is per process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-fit-high-populate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mmap&lt;/code&gt; with &lt;code&gt;MAP_POPULATE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the same bytes, charged in one system call per chunk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-clamp-high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;grows to 300 MiB, then sets its own &lt;code&gt;memory.high&lt;/code&gt; so its usage is 33% over it&lt;/td&gt;
&lt;td&gt;the 2 s cap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*-owncode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a fresh copy of the interpreter binary&lt;/td&gt;
&lt;td&gt;a program whose code pages are charged to its own cgroup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file-over-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reads 320 MiB of a 512 MiB file&lt;/td&gt;
&lt;td&gt;page cache instead of anonymous memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-over-both-long&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;anon-over&lt;/code&gt; with both limits, 45 minute cap&lt;/td&gt;
&lt;td&gt;how long until the OOM kill&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each of &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt; and &lt;code&gt;both&lt;/code&gt; ran 3 times, in a fixed order per repetition, with a 150 second cap. The &lt;code&gt;owncode&lt;/code&gt; runs thrashed the disk in the smoke tests, so we capped them at 20 seconds. We also capped every run's disk reads and writes at 100 MiB/s with &lt;code&gt;io.max&lt;/code&gt; on its own cgroup, so that a thrashing run would not take the shared disk from other work. One run, &lt;code&gt;anon-over-max-owncode-fastdisk&lt;/code&gt;, keeps the uncapped disk.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/cgroup-memory-high-vs-max" rel="noopener noreferrer"&gt;The-DevOps-Daily/cgroup-memory-high-vs-max on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We wrote the claim and what would disprove it before the first run and committed it first (&lt;code&gt;CLAIM.md&lt;/code&gt;). The smoke runs then changed the design twice, and we added those changes to the same file before the main matrix. Every run directory carries a hash of the run code. The summary script refuses a batch with mixed hashes, or a hash that matches no commit in the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  memory.max: full speed, then the kill
&lt;/h2&gt;

&lt;p&gt;With only &lt;code&gt;memory.max&lt;/code&gt;, the workload never slowed down. In the three &lt;code&gt;anon-over-max&lt;/code&gt; runs the median operation took 0.20 to 0.27 ms, the p99 of the last 10% of operations before the kill was 0.29 to 0.41 ms, and the kernel killed the process 0.28 to 0.39 seconds after it started (&lt;code&gt;results/summary.md&lt;/code&gt;). In each run &lt;code&gt;memory.events&lt;/code&gt; counted 35 times that a charge hit the limit, then one OOM kill. The kernel log names the cgroup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=ddmemhigh-20261105T112606Z.scope,mems_allowed=0,oom_memcg=/ddmemhigh.slice/ddmemhigh-20261105T112606Z.scope/anon-over-max-r1,task_memcg=/ddmemhigh.slice/ddmemhigh-20261105T112606Z.scope/anon-over-max-r1,task=python3.12,pid=142949,uid=0
Memory cgroup out of memory: Killed process 142949 (python3.12) total-vm:276772kB, anon-rss:261504kB, file-rss:7296kB, shmem-rss:0kB, UID:0 pgtables:580kB oom_score_adj:0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the cgroup could be reclaimed. The cgroup held only the program's anonymous pages, and without swap the kernel cannot free those. So reclaim found nothing, the charge path ran out of retries, and the OOM killer ran. Memory pressure stayed at 0: the task never waited for memory. It was killed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;anon-fit-max&lt;/code&gt; runs are the other half of the same result. That workload needs 239.2 MiB, which fits under 256 MiB. It finished all 200 churn operations with a median of 0.277 ms per churn operation and a &lt;code&gt;max&lt;/code&gt; counter of 0. With no limit, the same median was 0.257 ms. If a program fits under &lt;code&gt;memory.max&lt;/code&gt;, the limit costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  memory.high: slower and slower, not killed
&lt;/h2&gt;

&lt;p&gt;With only &lt;code&gt;memory.high&lt;/code&gt;, the same 320 MiB workload was not killed in any of the 3 runs within the 150 second cap. The kernel docs say &lt;code&gt;memory.high&lt;/code&gt; never invokes the OOM killer, and our driver stopped each run at the cap. Once it was a few MiB over the limit, it moved at the speed of the kernel's sleep timer, not at the speed of a program:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency of one 256 KiB allocation vs memory.current&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;226&lt;/th&gt;
&lt;th&gt;227&lt;/th&gt;
&lt;th&gt;228&lt;/th&gt;
&lt;th&gt;229&lt;/th&gt;
&lt;th&gt;230&lt;/th&gt;
&lt;th&gt;231&lt;/th&gt;
&lt;th&gt;232&lt;/th&gt;
&lt;th&gt;233&lt;/th&gt;
&lt;th&gt;234&lt;/th&gt;
&lt;th&gt;235&lt;/th&gt;
&lt;th&gt;236&lt;/th&gt;
&lt;th&gt;237&lt;/th&gt;
&lt;th&gt;238&lt;/th&gt;
&lt;th&gt;239&lt;/th&gt;
&lt;th&gt;240&lt;/th&gt;
&lt;th&gt;241&lt;/th&gt;
&lt;th&gt;242&lt;/th&gt;
&lt;th&gt;243&lt;/th&gt;
&lt;th&gt;244&lt;/th&gt;
&lt;th&gt;245&lt;/th&gt;
&lt;th&gt;246&lt;/th&gt;
&lt;th&gt;247&lt;/th&gt;
&lt;th&gt;248&lt;/th&gt;
&lt;th&gt;249&lt;/th&gt;
&lt;th&gt;250&lt;/th&gt;
&lt;th&gt;251&lt;/th&gt;
&lt;th&gt;252&lt;/th&gt;
&lt;th&gt;253&lt;/th&gt;
&lt;th&gt;254&lt;/th&gt;
&lt;th&gt;255&lt;/th&gt;
&lt;th&gt;256&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no limit&lt;/td&gt;
&lt;td&gt;0.248ms&lt;/td&gt;
&lt;td&gt;0.252ms&lt;/td&gt;
&lt;td&gt;0.249ms&lt;/td&gt;
&lt;td&gt;0.251ms&lt;/td&gt;
&lt;td&gt;0.246ms&lt;/td&gt;
&lt;td&gt;0.253ms&lt;/td&gt;
&lt;td&gt;0.242ms&lt;/td&gt;
&lt;td&gt;0.26ms&lt;/td&gt;
&lt;td&gt;0.24ms&lt;/td&gt;
&lt;td&gt;0.252ms&lt;/td&gt;
&lt;td&gt;0.25ms&lt;/td&gt;
&lt;td&gt;0.256ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.24ms&lt;/td&gt;
&lt;td&gt;0.213ms&lt;/td&gt;
&lt;td&gt;0.226ms&lt;/td&gt;
&lt;td&gt;0.238ms&lt;/td&gt;
&lt;td&gt;0.242ms&lt;/td&gt;
&lt;td&gt;0.235ms&lt;/td&gt;
&lt;td&gt;0.215ms&lt;/td&gt;
&lt;td&gt;0.223ms&lt;/td&gt;
&lt;td&gt;0.224ms&lt;/td&gt;
&lt;td&gt;0.225ms&lt;/td&gt;
&lt;td&gt;0.214ms&lt;/td&gt;
&lt;td&gt;0.253ms&lt;/td&gt;
&lt;td&gt;0.234ms&lt;/td&gt;
&lt;td&gt;0.235ms&lt;/td&gt;
&lt;td&gt;0.215ms&lt;/td&gt;
&lt;td&gt;0.239ms&lt;/td&gt;
&lt;td&gt;0.218ms&lt;/td&gt;
&lt;td&gt;0.222ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory.max only&lt;/td&gt;
&lt;td&gt;0.236ms&lt;/td&gt;
&lt;td&gt;0.224ms&lt;/td&gt;
&lt;td&gt;0.242ms&lt;/td&gt;
&lt;td&gt;0.195ms&lt;/td&gt;
&lt;td&gt;0.242ms&lt;/td&gt;
&lt;td&gt;0.223ms&lt;/td&gt;
&lt;td&gt;0.238ms&lt;/td&gt;
&lt;td&gt;0.221ms&lt;/td&gt;
&lt;td&gt;0.211ms&lt;/td&gt;
&lt;td&gt;0.216ms&lt;/td&gt;
&lt;td&gt;0.224ms&lt;/td&gt;
&lt;td&gt;0.203ms&lt;/td&gt;
&lt;td&gt;0.215ms&lt;/td&gt;
&lt;td&gt;0.199ms&lt;/td&gt;
&lt;td&gt;0.207ms&lt;/td&gt;
&lt;td&gt;0.19ms&lt;/td&gt;
&lt;td&gt;0.202ms&lt;/td&gt;
&lt;td&gt;0.204ms&lt;/td&gt;
&lt;td&gt;0.246ms&lt;/td&gt;
&lt;td&gt;0.199ms&lt;/td&gt;
&lt;td&gt;0.233ms&lt;/td&gt;
&lt;td&gt;0.232ms&lt;/td&gt;
&lt;td&gt;0.191ms&lt;/td&gt;
&lt;td&gt;0.209ms&lt;/td&gt;
&lt;td&gt;0.229ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.216ms&lt;/td&gt;
&lt;td&gt;0.225ms&lt;/td&gt;
&lt;td&gt;0.233ms&lt;/td&gt;
&lt;td&gt;0.238ms&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory.high only, sampler on the same CPU&lt;/td&gt;
&lt;td&gt;0.226ms&lt;/td&gt;
&lt;td&gt;0.216ms&lt;/td&gt;
&lt;td&gt;0.2ms&lt;/td&gt;
&lt;td&gt;0.206ms&lt;/td&gt;
&lt;td&gt;0.235ms&lt;/td&gt;
&lt;td&gt;0.228ms&lt;/td&gt;
&lt;td&gt;0.22ms&lt;/td&gt;
&lt;td&gt;25.621ms&lt;/td&gt;
&lt;td&gt;39.782ms&lt;/td&gt;
&lt;td&gt;209.811ms&lt;/td&gt;
&lt;td&gt;1525.021ms&lt;/td&gt;
&lt;td&gt;4583.747ms&lt;/td&gt;
&lt;td&gt;5839.866ms&lt;/td&gt;
&lt;td&gt;7303.861ms&lt;/td&gt;
&lt;td&gt;8839.941ms&lt;/td&gt;
&lt;td&gt;10295.852ms&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory.high only, own CPU, sampler at 1 s&lt;/td&gt;
&lt;td&gt;0.22ms&lt;/td&gt;
&lt;td&gt;0.259ms&lt;/td&gt;
&lt;td&gt;0.258ms&lt;/td&gt;
&lt;td&gt;0.272ms&lt;/td&gt;
&lt;td&gt;0.248ms&lt;/td&gt;
&lt;td&gt;0.27ms&lt;/td&gt;
&lt;td&gt;0.262ms&lt;/td&gt;
&lt;td&gt;14.873ms&lt;/td&gt;
&lt;td&gt;106.819ms&lt;/td&gt;
&lt;td&gt;519.352ms&lt;/td&gt;
&lt;td&gt;52.8ms&lt;/td&gt;
&lt;td&gt;71.752ms&lt;/td&gt;
&lt;td&gt;95.774ms&lt;/td&gt;
&lt;td&gt;119.723ms&lt;/td&gt;
&lt;td&gt;135.811ms&lt;/td&gt;
&lt;td&gt;171.815ms&lt;/td&gt;
&lt;td&gt;191.793ms&lt;/td&gt;
&lt;td&gt;323.75ms&lt;/td&gt;
&lt;td&gt;383.851ms&lt;/td&gt;
&lt;td&gt;851.785ms&lt;/td&gt;
&lt;td&gt;11251.414ms&lt;/td&gt;
&lt;td&gt;379.811ms&lt;/td&gt;
&lt;td&gt;1847.768ms&lt;/td&gt;
&lt;td&gt;1759.826ms&lt;/td&gt;
&lt;td&gt;1135.82ms&lt;/td&gt;
&lt;td&gt;1727.849ms&lt;/td&gt;
&lt;td&gt;3199.782ms&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kernel formula, 1 sleep per 64 pages&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;11.243ms&lt;/td&gt;
&lt;td&gt;20.243ms&lt;/td&gt;
&lt;td&gt;31.243ms&lt;/td&gt;
&lt;td&gt;44.243ms&lt;/td&gt;
&lt;td&gt;60.243ms&lt;/td&gt;
&lt;td&gt;79.243ms&lt;/td&gt;
&lt;td&gt;99.243ms&lt;/td&gt;
&lt;td&gt;123.243ms&lt;/td&gt;
&lt;td&gt;148.243ms&lt;/td&gt;
&lt;td&gt;176.243ms&lt;/td&gt;
&lt;td&gt;206.243ms&lt;/td&gt;
&lt;td&gt;239.243ms&lt;/td&gt;
&lt;td&gt;274.243ms&lt;/td&gt;
&lt;td&gt;312.243ms&lt;/td&gt;
&lt;td&gt;352.243ms&lt;/td&gt;
&lt;td&gt;395.243ms&lt;/td&gt;
&lt;td&gt;439.243ms&lt;/td&gt;
&lt;td&gt;487.243ms&lt;/td&gt;
&lt;td&gt;536.243ms&lt;/td&gt;
&lt;td&gt;588.243ms&lt;/td&gt;
&lt;td&gt;643.243ms&lt;/td&gt;
&lt;td&gt;700.243ms&lt;/td&gt;
&lt;td&gt;759.243ms&lt;/td&gt;
&lt;td&gt;821.243ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kernel formula, 1 sleep per page&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;0.243ms&lt;/td&gt;
&lt;td&gt;704.243ms&lt;/td&gt;
&lt;td&gt;1280.243ms&lt;/td&gt;
&lt;td&gt;1984.243ms&lt;/td&gt;
&lt;td&gt;2816.243ms&lt;/td&gt;
&lt;td&gt;3840.243ms&lt;/td&gt;
&lt;td&gt;5056.243ms&lt;/td&gt;
&lt;td&gt;6336.243ms&lt;/td&gt;
&lt;td&gt;7872.243ms&lt;/td&gt;
&lt;td&gt;9472.243ms&lt;/td&gt;
&lt;td&gt;11264.243ms&lt;/td&gt;
&lt;td&gt;13184.243ms&lt;/td&gt;
&lt;td&gt;15296.243ms&lt;/td&gt;
&lt;td&gt;17536.243ms&lt;/td&gt;
&lt;td&gt;19968.243ms&lt;/td&gt;
&lt;td&gt;22528.243ms&lt;/td&gt;
&lt;td&gt;25280.243ms&lt;/td&gt;
&lt;td&gt;28096.243ms&lt;/td&gt;
&lt;td&gt;31168.243ms&lt;/td&gt;
&lt;td&gt;34304.243ms&lt;/td&gt;
&lt;td&gt;37632.243ms&lt;/td&gt;
&lt;td&gt;41152.243ms&lt;/td&gt;
&lt;td&gt;44800.243ms&lt;/td&gt;
&lt;td&gt;48576.243ms&lt;/td&gt;
&lt;td&gt;52544.243ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;1 ms: 1ms&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;x axis: memory.current in MiB when the op started (memory.high = 230.4 MiB, memory.max = 256 MiB). Median per 1 MiB over 3 repetitions, warm code, Ubuntu 6.8.0-147, no swap. 'Sampler on the same CPU' is the main matrix (anon-over-high: the workload inherited the driver's CPU 0, sampled every 100 ms); 'own CPU' is diag-over-high-cpu2-1s (workload on CPU 2, sampled every 1 s). memory.max only stays flat up to 256 MiB, then the OOM kill. The formula lines add the no-limit median to the kernel's sleep: once per 64-page op, or once per 4 KiB page. Source: results/runs/anon-over-&lt;/em&gt;/ops.jsonl, results/diag/diag-over-high-cpu2-1s-&lt;em&gt;/ops.jsonl, scripts/50-charts.py.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In the matrix runs (the orange line), up to 232 MiB an operation took about a quarter of a millisecond, the same as with no limit. Within the next 5 MiB the median went from 0.22 ms to 4.6 seconds, and at 241 MiB it was 10.3 seconds per 256 KiB. After 150 seconds &lt;code&gt;memory.current&lt;/code&gt; was 241.5 to 242.2 MiB, 11 to 12 MiB over &lt;code&gt;memory.high&lt;/code&gt;, and still growing by 25 to 30 KiB per second. The process used 0.59 to 0.72 seconds of CPU in those 150 seconds. In 98% to 99% of the samples it was in state &lt;code&gt;D&lt;/code&gt;, in &lt;code&gt;mem_cgroup_handle_over_high&lt;/code&gt;. The purple line is the same workload on a CPU of its own. It stays near the one-sleep-per-batch line for most of the range, with jumps when it falls into a slow phase. The section on how many sleeps a task pays explains both lines.&lt;/p&gt;

&lt;p&gt;Here is the same workload through systemd's &lt;code&gt;MemoryMax=&lt;/code&gt; and &lt;code&gt;MemoryHigh=&lt;/code&gt;, without our driver. The progress line shows the slowest operation in each second:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;results/25-demo.txt (no driver, no CPU pinning)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# commands as scripts/25-demo.sh prints them (it also passes --quiet --slice=ddmemhigh.slice); 320 MiB target
$ systemd-run --scope -p MemoryMax=256M python3 -I scripts/lib/workload.py --kind anon --target-mib 320 --progress
pid 173416 in /sys/fs/cgroup/ddmemhigh.slice/run-r765e1a07143e4aa1be21a9eb2292aaff.scope, memory.high=max memory.max=268435456
   0.0s held 0 MiB memory.current 7.0 MiB ops 1 slowest op 0.33 ms
scripts/25-demo.sh: line 17: 173416 Killed systemd-run --scope --quiet --slice=ddmemhigh.slice -p MemoryMax=256M $w
exit status 137
$ systemd-run --scope -p MemoryHigh=241590272 -p MemoryMax=256M python3 -I scripts/lib/workload.py --kind anon --target-mib 320 --progress --max-seconds 30
pid 173417 in /sys/fs/cgroup/ddmemhigh.slice/run-rbd28bd3d399c47a9ab104c3b6d4b6eaf.scope, memory.high=241590272 memory.max=268435456
   0.0s held 0 MiB memory.current 6.7 MiB ops 1 slowest op 0.30 ms
   1.0s held 231 MiB memory.current 238.2 MiB ops 924 slowest op 79.86 ms
   7.1s held 231 MiB memory.current 238.5 MiB ops 926 slowest op 5488.05 ms
  12.8s held 231 MiB memory.current 238.8 MiB ops 927 slowest op 5759.67 ms
  19.0s held 232 MiB memory.current 239.0 MiB ops 928 slowest op 6135.79 ms
  25.5s held 232 MiB memory.current 239.3 MiB ops 929 slowest op 6511.84 ms
  27.5s held 232 MiB memory.current 239.8 MiB ops 930 slowest op 1992.06 ms
  28.5s held 234 MiB memory.current 241.1 MiB ops 936 slowest op 359.79 ms
  29.6s held 234 MiB memory.current 241.9 MiB ops 939 slowest op 759.73 ms
end: time after 30.4s, 940 ops, held 235 MiB, memory.current 242.1 MiB
exit status 0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first process was killed before its second progress line, so in less than a second, with exit status 137. The second got 11.7 MiB past &lt;code&gt;memory.high&lt;/code&gt; in 30 seconds, and its operations took 5.5 to 6.5 seconds each until they sped up near the end. The next section explains that change.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;anon-fit&lt;/code&gt; runs matter more, because nothing in them is wrong. The program needs 239 MiB, fits under its 256 MiB &lt;code&gt;memory.max&lt;/code&gt;, and runs at full speed there. With &lt;code&gt;memory.high&lt;/code&gt; at 230.4 MiB added, it held about 239 MiB and every churn operation slept. In the matrix, where it shared its CPU with our sampler, the median churn operation took 6,216 ms instead of 0.257 ms, and it finished 15 to 17 of its 200 churn operations before the 150 second cap. That is about 24,000 times slower per operation, with the same memory use and no error anywhere. On a CPU of its own it was about 750 times slower. The next section explains the difference. &lt;code&gt;memory.max&lt;/code&gt; was never reached and its counter stayed at 0.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many sleeps: it depends on who else runs on the CPU
&lt;/h2&gt;

&lt;p&gt;To compare with the kernel, we count sleeps. In the warm runs reclaim never freed anything, so each sleep raised the &lt;code&gt;high&lt;/code&gt; counter by 17, and an operation's sleep count is its rise divided by 17. We skip operations whose rise is not a multiple of 17 (11 to 13 per run, all at the edge of the limit, where the formula gives 10 ms or less and the kernel does not sleep). Then the measured latency divided by (sleeps x formula) should be 1.&lt;/p&gt;

&lt;p&gt;It was 1.06 to 1.10 at the median in every single-process matrix run, and 97% to 100% of the throttled operations were within 30% of the formula (&lt;code&gt;results/predict.md&lt;/code&gt;, section 1). The formula is right about how long each sleep is. The section on the 2 second cap shows where part of the extra 6% to 10% comes from.&lt;/p&gt;

&lt;p&gt;The number of sleeps was the surprise. The kernel charges memory in batches of 64 pages and keeps the rest of a batch as a per-CPU precharge (&lt;code&gt;consume_stock()&lt;/code&gt;), so the code comment on the cap says "per allocation batch". A 64-page operation should sleep about once. In the matrix, once the sleeps were 50 ms or longer, almost every operation slept 64 times, once per page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Formula sleep length&lt;/th&gt;
&lt;th&gt;Operations&lt;/th&gt;
&lt;th&gt;Median sleeps per 64 pages&lt;/th&gt;
&lt;th&gt;Operations with 64 or more&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;11 to 25 ms&lt;/td&gt;
&lt;td&gt;83&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;24%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 to 50 ms&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 to 100 ms&lt;/td&gt;
&lt;td&gt;228&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100 to 200 ms&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200 to 400 ms&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400 to 2000 ms&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A new charge, and so a new sleep, only happens when the precharge is empty. &lt;code&gt;__refill_stock()&lt;/code&gt; empties a CPU's precharge when a different cgroup charges memory on that CPU. In review we found a cgroup that did exactly that in every run: our own driver. It pinned itself to CPU 0 before it started the workload, so every workload in the matrix inherited CPU 0 (the &lt;code&gt;cpu&lt;/code&gt; field of every operation says 0). The driver woke up every 100 ms to sample and parsed the files into Python objects, which charges memory to the driver's own cgroup on that CPU. We did not trace each drain, but the runs below show the effect.&lt;/p&gt;

&lt;p&gt;So we ran the 320 MiB &lt;code&gt;memory.high&lt;/code&gt; workload again, 3 times each, with the workload on CPU 0 or CPU 2 and the sampler every 100 ms or every second (&lt;code&gt;results/diag&lt;/code&gt;, 120 second cap):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload CPU&lt;/th&gt;
&lt;th&gt;Sampler&lt;/th&gt;
&lt;th&gt;Median sleeps per op (formula sleep 50 ms or more)&lt;/th&gt;
&lt;th&gt;Share of that time in ops with 32 or more sleeps&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;memory.current&lt;/code&gt; at the cap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0, with the sampler&lt;/td&gt;
&lt;td&gt;every 100 ms&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;241.3 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0, with the sampler&lt;/td&gt;
&lt;td&gt;every 1 s&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;46%&lt;/td&gt;
&lt;td&gt;248.8 to 249.8 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2, alone&lt;/td&gt;
&lt;td&gt;every 100 ms&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;53%&lt;/td&gt;
&lt;td&gt;250.0 to 252.0 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2, alone&lt;/td&gt;
&lt;td&gt;every 1 s&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;243.5 to 252.4 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the sampler on the same CPU every 100 ms, the task slept once per page for the whole run, as in the matrix: a median of 64 sleeps per operation, and 100% of the throttled time in operations with 32 or more sleeps. In the other three settings the median operation slept once or twice. But within each run the count switched between phases of about 1 sleep per operation and phases of 64, each lasting tens of seconds, and the slow phases still took 46% to 72% of the throttled time (&lt;code&gt;results/predict.md&lt;/code&gt;, section 1c). That is consistent with other memory activity on the host emptying the precharge in those phases, but we did not trace it. The demo session further down ran with no driver and no pinning at all, and it also paid 5.5 to 6.5 seconds per 256 KiB operation for 25 seconds before it sped up.&lt;/p&gt;

&lt;p&gt;So our sampler was not the only cause of the per-page cost. With it on the same CPU, the cost was constant. Read the matrix numbers as a task whose CPU also runs another cgroup that allocates memory every 100 ms. On its own CPU, the same &lt;code&gt;anon-fit&lt;/code&gt; workload with &lt;code&gt;memory.high&lt;/code&gt; took a median of 191.8 ms per churn operation in all 3 runs (&lt;code&gt;diag-fit-high-cpu2-1s&lt;/code&gt;), and finished all 200 churn operations in 2 of the 3 runs within 120 seconds. That is about 750 times the no-limit median, instead of about 24,000 times.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MAP_POPULATE&lt;/code&gt; is a second control. It faults in the whole chunk inside the &lt;code&gt;mmap()&lt;/code&gt; call, so the task returns to user space once per chunk. On CPU 0 with the 100 ms sampler, it slept 2 times in 592 of 600 churn operations and 3 times in the other 8, a median churn operation took 191.8 ms, and all 200 churn operations were done in about 41 seconds (&lt;code&gt;anon-fit-high-populate&lt;/code&gt;). How a program touches its memory changed the cost of the same &lt;code&gt;memory.high&lt;/code&gt; by a factor of 32 here, with the same neighbour on the CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2 second cap
&lt;/h2&gt;

&lt;p&gt;None of the runs above got far enough over &lt;code&gt;memory.high&lt;/code&gt; to reach the cap, because the throttle slowed them first. So &lt;code&gt;anon-clamp-high&lt;/code&gt; gets there another way: it grows to 300 MiB with no limit, then writes 241590272 to its own &lt;code&gt;memory.high&lt;/code&gt;. Its usage of 307.2 MiB is then 33.3% over the limit. The formula then asks for a sleep of about 7,100 ms, and the kernel caps it. Each churn operation then allocates one 4 KiB page.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Over &lt;code&gt;memory.high&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Formula without the cap&lt;/th&gt;
&lt;th&gt;Sleeps per operation&lt;/th&gt;
&lt;th&gt;Time per sleep, median&lt;/th&gt;
&lt;th&gt;min&lt;/th&gt;
&lt;th&gt;max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-clamp-high-r1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;33.3%&lt;/td&gt;
&lt;td&gt;7,106 ms&lt;/td&gt;
&lt;td&gt;1, 2&lt;/td&gt;
&lt;td&gt;2,047.9 ms&lt;/td&gt;
&lt;td&gt;2,047.3 ms&lt;/td&gt;
&lt;td&gt;2,064.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-clamp-high-r2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;33.3%&lt;/td&gt;
&lt;td&gt;7,108 ms&lt;/td&gt;
&lt;td&gt;1, 2&lt;/td&gt;
&lt;td&gt;2,047.9 ms&lt;/td&gt;
&lt;td&gt;2,021.8 ms&lt;/td&gt;
&lt;td&gt;2,048.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon-clamp-high-r3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;33.3%&lt;/td&gt;
&lt;td&gt;7,107 ms&lt;/td&gt;
&lt;td&gt;1, 2&lt;/td&gt;
&lt;td&gt;2,047.9 ms&lt;/td&gt;
&lt;td&gt;2,011.7 ms&lt;/td&gt;
&lt;td&gt;2,047.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The median sleep took 2,047.9 ms in all three runs, not 2,000 ms. The cap is 2000 jiffies, and at &lt;code&gt;HZ=1000&lt;/code&gt; the kernel's timer wheel keeps timeouts from 512 ms to 4,095 ms in 64 ms steps (&lt;code&gt;kernel/time/timer.c&lt;/code&gt;), so a 2,000 ms timeout fires at one of those steps. The median of 2,047.9 ms fits that. Single sleeps ranged from 2,011.7 to 2,064.9 ms, depending on where in a step the timer started. The same rounding, at 8 ms steps from 64 to 511 ms, explains part of the 6% to 10% we saw on the shorter sleeps: with half a step added, the median ratio for sleeps of 64 ms or more drops to 1.030 to 1.055 in the matrix runs. Under 64 ms, where the step is 1 ms, it stays at 1.054 to 1.091, and we did not find the rest (&lt;code&gt;results/predict.md&lt;/code&gt;, section 1).&lt;/p&gt;

&lt;p&gt;So the cap holds, but it is per sleep, not per allocation. In these runs each 4 KiB operation slept twice after the first one, so it took 4,096 ms. A 256 KiB allocation that sleeps once per page at the cap would take more than two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Both limits: high at 90% of max
&lt;/h2&gt;

&lt;p&gt;systemd's man page recommends both: "use &lt;code&gt;MemoryHigh=&lt;/code&gt; as the main control mechanism and use &lt;code&gt;MemoryMax=&lt;/code&gt; as the last line of defense". The idea is that the program slows down before it dies. The &lt;code&gt;anon-over-both&lt;/code&gt; runs show what that looks like with our 230.4 MiB and 256 MiB:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;memory.current from 10 s to 150 s, the workload asks for 320 MiB&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;10s&lt;/th&gt;
&lt;th&gt;20s&lt;/th&gt;
&lt;th&gt;30s&lt;/th&gt;
&lt;th&gt;40s&lt;/th&gt;
&lt;th&gt;50s&lt;/th&gt;
&lt;th&gt;60s&lt;/th&gt;
&lt;th&gt;70s&lt;/th&gt;
&lt;th&gt;80s&lt;/th&gt;
&lt;th&gt;90s&lt;/th&gt;
&lt;th&gt;100s&lt;/th&gt;
&lt;th&gt;110s&lt;/th&gt;
&lt;th&gt;120s&lt;/th&gt;
&lt;th&gt;130s&lt;/th&gt;
&lt;th&gt;140s&lt;/th&gt;
&lt;th&gt;150s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;memory.high only, 1 process&lt;/td&gt;
&lt;td&gt;237.3 MiB&lt;/td&gt;
&lt;td&gt;237.9 MiB&lt;/td&gt;
&lt;td&gt;238.4 MiB&lt;/td&gt;
&lt;td&gt;238.8 MiB&lt;/td&gt;
&lt;td&gt;239.2 MiB&lt;/td&gt;
&lt;td&gt;239.6 MiB&lt;/td&gt;
&lt;td&gt;239.9 MiB&lt;/td&gt;
&lt;td&gt;240.2 MiB&lt;/td&gt;
&lt;td&gt;240.5 MiB&lt;/td&gt;
&lt;td&gt;240.8 MiB&lt;/td&gt;
&lt;td&gt;241.1 MiB&lt;/td&gt;
&lt;td&gt;241.4 MiB&lt;/td&gt;
&lt;td&gt;241.6 MiB&lt;/td&gt;
&lt;td&gt;241.8 MiB&lt;/td&gt;
&lt;td&gt;242.1 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory.high only, 4 processes&lt;/td&gt;
&lt;td&gt;242.3 MiB&lt;/td&gt;
&lt;td&gt;243.4 MiB&lt;/td&gt;
&lt;td&gt;244.5 MiB&lt;/td&gt;
&lt;td&gt;245.6 MiB&lt;/td&gt;
&lt;td&gt;246.1 MiB&lt;/td&gt;
&lt;td&gt;246.6 MiB&lt;/td&gt;
&lt;td&gt;247.1 MiB&lt;/td&gt;
&lt;td&gt;247.5 MiB&lt;/td&gt;
&lt;td&gt;247.9 MiB&lt;/td&gt;
&lt;td&gt;248.3 MiB&lt;/td&gt;
&lt;td&gt;248.7 MiB&lt;/td&gt;
&lt;td&gt;249.1 MiB&lt;/td&gt;
&lt;td&gt;249.4 MiB&lt;/td&gt;
&lt;td&gt;249.8 MiB&lt;/td&gt;
&lt;td&gt;250.1 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory.high + memory.max&lt;/td&gt;
&lt;td&gt;236.8 MiB&lt;/td&gt;
&lt;td&gt;237.5 MiB&lt;/td&gt;
&lt;td&gt;238 MiB&lt;/td&gt;
&lt;td&gt;238.5 MiB&lt;/td&gt;
&lt;td&gt;238.9 MiB&lt;/td&gt;
&lt;td&gt;239.3 MiB&lt;/td&gt;
&lt;td&gt;239.7 MiB&lt;/td&gt;
&lt;td&gt;240 MiB&lt;/td&gt;
&lt;td&gt;240.3 MiB&lt;/td&gt;
&lt;td&gt;240.6 MiB&lt;/td&gt;
&lt;td&gt;240.9 MiB&lt;/td&gt;
&lt;td&gt;241.2 MiB&lt;/td&gt;
&lt;td&gt;241.4 MiB&lt;/td&gt;
&lt;td&gt;241.7 MiB&lt;/td&gt;
&lt;td&gt;241.9 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;memory.high: 230.4 MiB&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;memory.max: 256 MiB&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Median of 3 repetitions at each 10 s mark, sampled from outside the cgroup every 100 ms. memory.max only reaches 256 MiB and is killed in under 2 s, so it is not drawn. Source: results/runs/anon-over-&lt;/em&gt;/samples.jsonl.*&lt;/p&gt;

&lt;p&gt;Within the 150 second cap, &lt;code&gt;memory.high&lt;/code&gt; plus &lt;code&gt;memory.max&lt;/code&gt; behaved like &lt;code&gt;memory.high&lt;/code&gt; alone, because the process did not get near &lt;code&gt;memory.max&lt;/code&gt;. After 150 seconds all three runs were at 241.7 to 242.0 MiB, 14 MiB under the kill. The growth rate fits the model of one sleep per page: 4 KiB per sleep, at the formula's sleep length for the current usage, predicts 24.7 to 26.1 KiB/s at the end of the runs, and we measured 26.6 to 28.0 KiB/s over the last 60 seconds (&lt;code&gt;results/predict.md&lt;/code&gt;, section 5).&lt;/p&gt;

&lt;p&gt;To see when the kill comes, we ran the same workload once more with a 45 minute cap (&lt;code&gt;anon-over-both-long&lt;/code&gt;, one run, the same setup as the matrix, so on CPU 0 next to the sampler). The kernel killed it after 1,747.7 seconds, 29.1 minutes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;memory.high at 90% of memory.max: the climb to the OOM kill&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;1m&lt;/th&gt;
&lt;th&gt;2m&lt;/th&gt;
&lt;th&gt;3m&lt;/th&gt;
&lt;th&gt;4m&lt;/th&gt;
&lt;th&gt;5m&lt;/th&gt;
&lt;th&gt;6m&lt;/th&gt;
&lt;th&gt;7m&lt;/th&gt;
&lt;th&gt;8m&lt;/th&gt;
&lt;th&gt;9m&lt;/th&gt;
&lt;th&gt;10m&lt;/th&gt;
&lt;th&gt;11m&lt;/th&gt;
&lt;th&gt;12m&lt;/th&gt;
&lt;th&gt;13m&lt;/th&gt;
&lt;th&gt;14m&lt;/th&gt;
&lt;th&gt;15m&lt;/th&gt;
&lt;th&gt;16m&lt;/th&gt;
&lt;th&gt;17m&lt;/th&gt;
&lt;th&gt;18m&lt;/th&gt;
&lt;th&gt;19m&lt;/th&gt;
&lt;th&gt;20m&lt;/th&gt;
&lt;th&gt;21m&lt;/th&gt;
&lt;th&gt;22m&lt;/th&gt;
&lt;th&gt;23m&lt;/th&gt;
&lt;th&gt;24m&lt;/th&gt;
&lt;th&gt;25m&lt;/th&gt;
&lt;th&gt;26m&lt;/th&gt;
&lt;th&gt;27m&lt;/th&gt;
&lt;th&gt;28m&lt;/th&gt;
&lt;th&gt;29m&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;memory.current&lt;/td&gt;
&lt;td&gt;239.7 MiB&lt;/td&gt;
&lt;td&gt;241.4 MiB&lt;/td&gt;
&lt;td&gt;242.8 MiB&lt;/td&gt;
&lt;td&gt;243.9 MiB&lt;/td&gt;
&lt;td&gt;244.8 MiB&lt;/td&gt;
&lt;td&gt;245.7 MiB&lt;/td&gt;
&lt;td&gt;246.4 MiB&lt;/td&gt;
&lt;td&gt;247.1 MiB&lt;/td&gt;
&lt;td&gt;247.8 MiB&lt;/td&gt;
&lt;td&gt;248.4 MiB&lt;/td&gt;
&lt;td&gt;248.9 MiB&lt;/td&gt;
&lt;td&gt;249.5 MiB&lt;/td&gt;
&lt;td&gt;250 MiB&lt;/td&gt;
&lt;td&gt;250.5 MiB&lt;/td&gt;
&lt;td&gt;250.9 MiB&lt;/td&gt;
&lt;td&gt;251.3 MiB&lt;/td&gt;
&lt;td&gt;251.7 MiB&lt;/td&gt;
&lt;td&gt;252.1 MiB&lt;/td&gt;
&lt;td&gt;252.5 MiB&lt;/td&gt;
&lt;td&gt;252.8 MiB&lt;/td&gt;
&lt;td&gt;253.2 MiB&lt;/td&gt;
&lt;td&gt;253.5 MiB&lt;/td&gt;
&lt;td&gt;253.9 MiB&lt;/td&gt;
&lt;td&gt;254.2 MiB&lt;/td&gt;
&lt;td&gt;254.5 MiB&lt;/td&gt;
&lt;td&gt;254.8 MiB&lt;/td&gt;
&lt;td&gt;255.1 MiB&lt;/td&gt;
&lt;td&gt;255.4 MiB&lt;/td&gt;
&lt;td&gt;255.7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;memory.high: 230.4 MiB&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;memory.max: 256 MiB&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One run, sampled every 100 ms, one point a minute. Source: results/runs/anon-over-both-long-r1/samples.jsonl.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In those 29 minutes the process used 1.19 seconds of CPU, its memory pressure &lt;code&gt;some&lt;/code&gt; covered 1,742 seconds, and its slowest 256 KiB allocation took 53.2 seconds. From the point where the sleep starts (233.4 MiB, 0.3 seconds in), the formula with one sleep per page predicts 1,720 seconds to reach &lt;code&gt;memory.max&lt;/code&gt;, 1.6% less than we measured. With one sleep per 64 pages it would have been 27 seconds (&lt;code&gt;results/predict.md&lt;/code&gt;, section 3). Then the OOM kill came with 35 &lt;code&gt;max&lt;/code&gt; events, as in the &lt;code&gt;memory.max&lt;/code&gt; runs. &lt;code&gt;memory.high&lt;/code&gt; did not save the program. It delayed the same OOM kill by half an hour.&lt;/p&gt;

&lt;p&gt;On a CPU of its own (&lt;code&gt;diag-over-both-cpu2-1s&lt;/code&gt;, 3 runs, 120 second cap), the same workload was not killed within the cap either. It reached 240.8 to 254.4 MiB, depending on how much of the run fell into the one-sleep-per-page phases. So we have two reference points, not a proven range: the formula's 27 seconds if every sleep covers 64 pages, and the 29.1 minutes we measured once next to the sampler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The throttle is per process
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;memory.high&lt;/code&gt; puts each allocating task to sleep on its own. In the matrix, the four processes of &lt;code&gt;anon-over-4p-high&lt;/code&gt; all ran on CPU 0 next to the sampler. We cannot split &lt;code&gt;memory.events&lt;/code&gt; per process, but their combined growth matches the model of one sleep per page for each process: about four times as fast as one process at the same overage. In &lt;code&gt;anon-over-4p-high&lt;/code&gt; the four processes reached 249.1 to 250.8 MiB in 150 seconds, against 241.5 to 242.2 MiB for one process. Over the last 60 seconds they grew 34.0 to 42.3 KiB/s, where the one-sleep-per-page model predicts 31.9 to 38.0 KiB/s for four processes at that usage. A program with many allocating worker processes gets to &lt;code&gt;memory.max&lt;/code&gt; sooner than a single process.&lt;/p&gt;

&lt;p&gt;The four throttled processes also changed a number most dashboards show. While they slept, the host's load average was 5.33 on 4 vCPUs, and &lt;code&gt;vmstat&lt;/code&gt; showed the CPUs 96% and 99% idle in the two one-second samples:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;results/observed/loadavg-during-4p-high.txt&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# recorded during anon-over-4p-high-r1: ps, vmstat 1 3, /proc/loadavg, nproc
$ cat results/observed/loadavg-during-4p-high.txt
# 2026-11-05T11:39:12Z during anon-over-4p-high-r1 (ps, vmstat 1 3, /proc/loadavg)
 146187 D mem_cgroup_handle_over_high 0.2 /usr/bin/python3.12 -I /root/dd-memory-high/scripts/lib/wor
 146188 D mem_cgroup_handle_over_high 0.2 /usr/bin/python3.12 -I /root/dd-memory-high/scripts/lib/wor
 146189 D mem_cgroup_handle_over_high 0.2 /usr/bin/python3.12 -I /root/dd-memory-high/scripts/lib/wor
 146190 D mem_cgroup_handle_over_high 0.2 /usr/bin/python3.12 -I /root/dd-memory-high/scripts/lib/wor
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
 r b swpd free buff cache si so bi bo in cs us sy id wa st gu
 0 0 0 1407320 32980 5042700 0 0 835 650 876 2 4 1 94 0 1 0
 0 0 0 1410512 32980 5042720 0 0 0 0 1627 1417 2 3 96 0 0 0
 0 0 0 1413104 32980 5042736 0 0 0 28 621 787 1 0 99 0 0 0
5.33 3.56 2.27 1/347 147320
4

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel puts the task to sleep with &lt;code&gt;schedule_timeout_killable()&lt;/code&gt;. That is a killable sleep: only a fatal signal ends it early. &lt;code&gt;ps&lt;/code&gt; shows it as state &lt;code&gt;D&lt;/code&gt;, and Linux counts it in the load average like other tasks in state &lt;code&gt;D&lt;/code&gt;. A high load average with idle CPUs can mean throttled memory, not only slow disks.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the cgroup holds its own code, both limits thrash
&lt;/h2&gt;

&lt;p&gt;So far the interpreter's code pages were in the page cache before each run started, charged to a different cgroup. That is the &lt;code&gt;warm&lt;/code&gt; setup, and it is the reason the runs above had nothing to reclaim. A container on its first start is different: its binary and libraries are read from its image after it starts, so the page cache for its own code is charged to its own cgroup. The &lt;code&gt;owncode&lt;/code&gt; runs copy the interpreter binary to a new file, drop it from the page cache (&lt;code&gt;mincore()&lt;/code&gt; found 0 of its pages cached before each run), and run that copy, so the binary is read inside the run cgroup.&lt;/p&gt;

&lt;p&gt;That gives the kernel something to reclaim, and it changes both limits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run (20 s cap)&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;&lt;code&gt;memory.current&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Pages reclaimed&lt;/th&gt;
&lt;th&gt;Read from disk&lt;/th&gt;
&lt;th&gt;PSI memory &lt;code&gt;some&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;PSI io &lt;code&gt;some&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;memory.high&lt;/code&gt; only, 232 MiB target&lt;/td&gt;
&lt;td&gt;not killed in 20 s (3 of 3)&lt;/td&gt;
&lt;td&gt;230.4 MiB&lt;/td&gt;
&lt;td&gt;591,486 to 617,906&lt;/td&gt;
&lt;td&gt;2,426 to 2,535 MB&lt;/td&gt;
&lt;td&gt;11.4 to 11.9 s&lt;/td&gt;
&lt;td&gt;19.9 to 21.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;both, 320 MiB target&lt;/td&gt;
&lt;td&gt;not killed in 20 s (3 of 3)&lt;/td&gt;
&lt;td&gt;230.2 to 230.4 MiB&lt;/td&gt;
&lt;td&gt;633,090 to 634,192&lt;/td&gt;
&lt;td&gt;2,593 to 2,598 MB&lt;/td&gt;
&lt;td&gt;12.4 to 14.3 s&lt;/td&gt;
&lt;td&gt;21.3 to 21.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;memory.max&lt;/code&gt; only, 320 MiB target&lt;/td&gt;
&lt;td&gt;not killed in 20 s (3 of 3)&lt;/td&gt;
&lt;td&gt;255.9 MiB&lt;/td&gt;
&lt;td&gt;631,197 to 633,258&lt;/td&gt;
&lt;td&gt;2,585 to 2,594 MB&lt;/td&gt;
&lt;td&gt;1.9 to 2.1 s&lt;/td&gt;
&lt;td&gt;21.3 to 21.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;memory.max&lt;/code&gt; only, uncapped disk, 1 run&lt;/td&gt;
&lt;td&gt;not killed in 20 s (1 of 1)&lt;/td&gt;
&lt;td&gt;256.0 MiB&lt;/td&gt;
&lt;td&gt;2,559,211&lt;/td&gt;
&lt;td&gt;10,483 MB&lt;/td&gt;
&lt;td&gt;6.2 s&lt;/td&gt;
&lt;td&gt;13.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under &lt;code&gt;memory.high&lt;/code&gt;, reclaim kept succeeding, and what it took was the program's own code. The task faulted it back in from disk, reclaim took it again, and so on. Because reclaim kept freeing pages, the kernel's retry loop never got to the sleep (&lt;code&gt;if (nr_reclaimed || nr_retries--)&lt;/code&gt; retries as long as anything was freed). The sampler never found the process in &lt;code&gt;mem_cgroup_handle_over_high&lt;/code&gt;, and only 1 of the 108 operations that raised the &lt;code&gt;high&lt;/code&gt; counter raised it by a multiple of 17. The process sat at 230.2 to 230.4 MiB, right at &lt;code&gt;memory.high&lt;/code&gt;. In 54% to 90% of the samples it was in &lt;code&gt;folio_wait_bit_common&lt;/code&gt;, waiting for its code to come back from disk, and in the rest it was running. It never finished its grow phase.&lt;/p&gt;

&lt;p&gt;Under &lt;code&gt;memory.max&lt;/code&gt; alone, the same thing replaced the OOM kill. The charge path only calls the OOM killer after 16 tries in which reclaim freed nothing, and here reclaim kept finding code pages to free. The process held 255.9 MiB and was not killed in any of the three 20 second runs. &lt;code&gt;memory.events&lt;/code&gt; counted 19,140 to 20,019 &lt;code&gt;max&lt;/code&gt; events per run, and no OOM. On the uncapped disk, it read 10,483 MB in 25 seconds and was not killed either. In an earlier smoke run with the same setup (older code, in &lt;code&gt;results/_smoke5&lt;/code&gt;), the kernel did kill it, 21.7 seconds after it started. So the kill does come in some runs, after a period of thrashing we could not predict. In another smoke run on the uncapped disk (&lt;code&gt;results/_smoke3&lt;/code&gt;) it was still thrashing at 70 seconds when our driver stopped it.&lt;/p&gt;

&lt;p&gt;Memory PSI under-counts this. In the &lt;code&gt;memory.max&lt;/code&gt; runs with the 100 MiB/s cap, memory &lt;code&gt;some&lt;/code&gt; was 1.9 to 2.1 seconds out of 25, about 8%, while io &lt;code&gt;some&lt;/code&gt; was 21.3 to 21.7 seconds. The kernel counts a wait for a page read as a memory stall only when the page carries the workingset flag (&lt;code&gt;folio_wait_bit_common()&lt;/code&gt; in &lt;code&gt;mm/filemap.c&lt;/code&gt;), and most of this time went to the disk. A pressure-based killer that watches memory pressure alone would not see this as clearly as the warm &lt;code&gt;memory.high&lt;/code&gt; runs, where memory &lt;code&gt;some&lt;/code&gt; was 99%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Page cache: neither limit throttles or kills
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;file-over&lt;/code&gt; runs read 320 MiB of a file in 256 KiB reads, then read random chunks of it. That is page cache, which the kernel can drop and read again. None of the 12 runs was killed or throttled:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Peak MiB&lt;/th&gt;
&lt;th&gt;Pages reclaimed&lt;/th&gt;
&lt;th&gt;PSI memory &lt;code&gt;some&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;p99 per read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no limit&lt;/td&gt;
&lt;td&gt;not killed (3 of 3)&lt;/td&gt;
&lt;td&gt;327.7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.00 s&lt;/td&gt;
&lt;td&gt;82.1 to 86.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;memory.max&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;not killed (3 of 3)&lt;/td&gt;
&lt;td&gt;256.0&lt;/td&gt;
&lt;td&gt;20,608 to 21,440&lt;/td&gt;
&lt;td&gt;0.02 s&lt;/td&gt;
&lt;td&gt;83.4 to 88.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;memory.high&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;not killed (3 of 3)&lt;/td&gt;
&lt;td&gt;230.9&lt;/td&gt;
&lt;td&gt;28,352 to 28,864&lt;/td&gt;
&lt;td&gt;0.03 s&lt;/td&gt;
&lt;td&gt;85.0 to 87.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;both&lt;/td&gt;
&lt;td&gt;not killed (3 of 3)&lt;/td&gt;
&lt;td&gt;230.9&lt;/td&gt;
&lt;td&gt;28,352 to 28,864&lt;/td&gt;
&lt;td&gt;0.02 to 0.03 s&lt;/td&gt;
&lt;td&gt;82.9 to 89.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both limits did what the docs say: &lt;code&gt;memory.current&lt;/code&gt; stopped at the limit and the kernel reclaimed file pages to stay there. &lt;code&gt;memory.events&lt;/code&gt; counted 416 to 430 &lt;code&gt;high&lt;/code&gt; events and 322 to 335 &lt;code&gt;max&lt;/code&gt; events per run. Every &lt;code&gt;high&lt;/code&gt; event was a single reclaim pass with no sleep: the rise per operation was 1 or 2, never 17, because each pass freed enough to get back under the limit. The p99 of 82 to 90 ms is the same with no limit, so it is not the memory limits. The slow reads are in the first pass over the file, read from disk under our 100 MiB/s cap. The limits did cost something later: in the last 10% of operations, random reads that had to go back to disk gave a p99 of 1.0 to 2.6 ms, against 0.13 ms with no limit, where the whole file stayed cached. For a cache-heavy program without swap, both limits are a cache size, not a throttle and not a kill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PSI and the counters show
&lt;/h2&gt;

&lt;p&gt;PSI was a reliable witness for the warm throttle. In every warm single-process matrix run with &lt;code&gt;memory.high&lt;/code&gt;, the cgroup's &lt;code&gt;memory.pressure&lt;/code&gt; &lt;code&gt;some&lt;/code&gt; total matched the time the operations spent over their normal latency, at a ratio of 0.998 to 1.068 (&lt;code&gt;results/predict.md&lt;/code&gt;, section 4). In the diagnostic runs it was 0.997 to 1.286. PSI also counts the last operation, which was still asleep when the driver stopped the run, and our operation log does not, so the ratio goes above 1 in runs that ended in the middle of a long operation. With one task in the cgroup, &lt;code&gt;full&lt;/code&gt; was the same as &lt;code&gt;some&lt;/code&gt;, and &lt;code&gt;avg10&lt;/code&gt; reached 97% to 99%. The host saw it too. During the long run, the host-wide &lt;code&gt;/proc/pressure/memory&lt;/code&gt; read &lt;code&gt;some avg10=96.53&lt;/code&gt; and &lt;code&gt;full avg10=95.09&lt;/code&gt; (&lt;code&gt;results/observed/host-psi-during-long.txt&lt;/code&gt;). Our test cgroup was the one we know was stalled, and the host-wide number was almost as high, so an alert on host memory pressure would fire.&lt;/p&gt;

&lt;p&gt;The counters to watch, from these runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;memory.events&lt;/code&gt; &lt;code&gt;high&lt;/code&gt; rising: the cgroup is over &lt;code&gt;memory.high&lt;/code&gt;. 17 per sleep when nothing is reclaimable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;memory.events&lt;/code&gt; &lt;code&gt;max&lt;/code&gt; rising without &lt;code&gt;oom_kill&lt;/code&gt;: the cgroup is at &lt;code&gt;memory.max&lt;/code&gt; and reclaim keeps finding something. In the own-code runs that meant thrashing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;memory.stat&lt;/code&gt; &lt;code&gt;pgsteal&lt;/code&gt; and &lt;code&gt;workingset_refault_file&lt;/code&gt; rising together: the cgroup reclaims pages and reads them straight back.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;memory.pressure&lt;/code&gt; &lt;code&gt;some&lt;/code&gt; near 100% with almost no CPU use and state &lt;code&gt;D&lt;/code&gt;: throttled by &lt;code&gt;memory.high&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;io.pressure&lt;/code&gt; high while &lt;code&gt;memory.pressure&lt;/code&gt; is low, in a cgroup at its memory limit: thrashing on file pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What systemd, Docker and Kubernetes write
&lt;/h2&gt;

&lt;h3&gt;
  
  
  systemd
&lt;/h3&gt;

&lt;p&gt;systemd maps its properties one to one. We started transient scopes with each property and read the files back from inside (&lt;code&gt;results/10-systemd-map.txt&lt;/code&gt;, systemd 255):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Value we set&lt;/th&gt;
&lt;th&gt;Value in the file&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemoryMin=16M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;memory.min&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16M&lt;/td&gt;
&lt;td&gt;16777216&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemoryLow=64M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;memory.low&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;64M&lt;/td&gt;
&lt;td&gt;67108864&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemoryHigh=241590272&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;memory.high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;241590272&lt;/td&gt;
&lt;td&gt;241590272&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemoryMax=256M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;memory.max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;256M&lt;/td&gt;
&lt;td&gt;268435456&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MemoryHigh=3%&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;memory.high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3% of RAM&lt;/td&gt;
&lt;td&gt;249806848&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A property you do not set stays at the kernel default: &lt;code&gt;max&lt;/code&gt; for &lt;code&gt;memory.high&lt;/code&gt; and &lt;code&gt;memory.max&lt;/code&gt;, 0 for &lt;code&gt;memory.min&lt;/code&gt; and &lt;code&gt;memory.low&lt;/code&gt;. &lt;code&gt;memory.oom.group&lt;/code&gt; stayed 0 in every scope.&lt;/p&gt;

&lt;p&gt;The systemd 255 man page says &lt;code&gt;MemoryHigh=&lt;/code&gt; is "the main mechanism to control memory usage of a unit", and it recommends &lt;code&gt;MemoryMax=&lt;/code&gt; "as the last line of defense". On a host with swap, the kernel can move cold anonymous pages out and hold the unit near the limit. We did not test that. Without swap, our runs show what "main mechanism" means: the unit does not stop at the limit, it slows down, and with &lt;code&gt;MemoryHigh=&lt;/code&gt; alone the kernel never kills it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;MemoryMin=&lt;/code&gt; and &lt;code&gt;MemoryLow=&lt;/code&gt; are the other direction. They protect a unit's memory from reclaim when something else needs it. They do not limit or slow the unit itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker
&lt;/h3&gt;

&lt;p&gt;Docker 29.9.3 has no flag for &lt;code&gt;memory.high&lt;/code&gt;. &lt;code&gt;docker run --help&lt;/code&gt; on the test host lists &lt;code&gt;--memory&lt;/code&gt;, &lt;code&gt;--memory-reservation&lt;/code&gt;, &lt;code&gt;--memory-swap&lt;/code&gt;, &lt;code&gt;--memory-swappiness&lt;/code&gt;, &lt;code&gt;--oom-kill-disable&lt;/code&gt; and &lt;code&gt;--oom-score-adj&lt;/code&gt; (&lt;code&gt;results/00-host.txt&lt;/code&gt;). We followed the two memory flags through the source (&lt;code&gt;results/01-source-check.txt&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker puts &lt;code&gt;--memory&lt;/code&gt; into the OCI spec as &lt;code&gt;memory.limit&lt;/code&gt; and &lt;code&gt;--memory-reservation&lt;/code&gt; as &lt;code&gt;memory.reservation&lt;/code&gt; (&lt;code&gt;getMemoryResources&lt;/code&gt; in moby &lt;code&gt;daemon/daemon_unix.go&lt;/code&gt;, docker-v29.9.3).&lt;/li&gt;
&lt;li&gt;runc 1.5.2, the host's runtime, writes the limit to &lt;code&gt;memory.max&lt;/code&gt; and the reservation to &lt;code&gt;memory.low&lt;/code&gt; on cgroup v2 (&lt;code&gt;fs2/memory.go&lt;/code&gt;). With the systemd cgroup driver it sets the &lt;code&gt;MemoryMax&lt;/code&gt; and &lt;code&gt;MemoryLow&lt;/code&gt; properties (&lt;code&gt;systemd/v2.go&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So on cgroup v2, &lt;code&gt;--memory&lt;/code&gt; is a hard limit and &lt;code&gt;--memory-reservation&lt;/code&gt; is reclaim protection. The Docker docs call &lt;code&gt;--memory-reservation&lt;/code&gt; "a soft limit smaller than &lt;code&gt;--memory&lt;/code&gt; which is activated when Docker detects contention or low memory on the host machine." That wording comes from cgroup v1's soft limit. On v2 it is &lt;code&gt;memory.low&lt;/code&gt;: it does not slow the container down at any size.&lt;/p&gt;

&lt;p&gt;runc does accept &lt;code&gt;memory.high&lt;/code&gt; through the OCI spec's &lt;code&gt;unified&lt;/code&gt; map, and passes it on as the systemd &lt;code&gt;MemoryHigh&lt;/code&gt; property. That is the path the kubelet uses. The Docker CLI does not expose it, so with plain &lt;code&gt;docker run&lt;/code&gt; you only get the &lt;code&gt;memory.max&lt;/code&gt; behaviour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes MemoryQoS
&lt;/h3&gt;

&lt;p&gt;The kubelet sets &lt;code&gt;memory.max&lt;/code&gt; from &lt;code&gt;limits.memory&lt;/code&gt; on cgroup v2 in every version. &lt;code&gt;memory.high&lt;/code&gt; is a separate feature, &lt;strong&gt;MemoryQoS&lt;/strong&gt; (&lt;a href="https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/2570-memory-qos" rel="noopener noreferrer"&gt;KEP-2570&lt;/a&gt;), and its status changed in the last two releases. All of this is from &lt;code&gt;results/01-source-check.txt&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Kubernetes 1.36 (v1.36.6)&lt;/th&gt;
&lt;th&gt;Kubernetes 1.37 (v1.37.2)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MemoryQoS&lt;/code&gt; feature gate&lt;/td&gt;
&lt;td&gt;alpha, off by default&lt;/td&gt;
&lt;td&gt;beta, on by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;memoryThrottlingFactor&lt;/code&gt; default&lt;/td&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;nil: no &lt;code&gt;memory.high&lt;/code&gt; unless you set it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;memoryReservationPolicy&lt;/code&gt; default&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;None&lt;/code&gt;: &lt;code&gt;memory.min&lt;/code&gt; and &lt;code&gt;memory.low&lt;/code&gt; are 0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the gate on and a factor set, the kubelet computes, for each container whose memory request differs from its limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;memory.high = floor((requests.memory + factor * (limits.memory - requests.memory)) / pageSize) * pageSize

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no limit, it uses node allocatable memory instead of &lt;code&gt;limits.memory&lt;/code&gt;. It puts the value in the CRI container config's &lt;code&gt;unified&lt;/code&gt; map under the key &lt;code&gt;memory.high&lt;/code&gt; (&lt;code&gt;kuberuntime_container_linux.go&lt;/code&gt;), and the runtime writes it to the container's cgroup. runc passes a unified &lt;code&gt;memory.high&lt;/code&gt; on as the systemd &lt;code&gt;MemoryHigh&lt;/code&gt; property when it uses the systemd driver. In 1.36, a container whose memory request equals its limit gets no &lt;code&gt;memory.high&lt;/code&gt;. 1.37 makes one exception: a container with no request and no limit can still get one when a factor is set.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;memory.high&lt;/code&gt; value is this formula for &lt;code&gt;limits.memory: 256Mi&lt;/code&gt;, &lt;code&gt;requests.memory: 0&lt;/code&gt; and factor 0.9: 241590272 bytes. Two details matter here. If a pod sets a limit and leaves the request out, the API server sets the request equal to the limit (&lt;code&gt;SetDefaults_Pod&lt;/code&gt; in &lt;code&gt;pkg/apis/core/v1/defaults.go&lt;/code&gt;), and the kubelet then writes no &lt;code&gt;memory.high&lt;/code&gt; at all. And with a request R between 0 and the limit, &lt;code&gt;memory.high&lt;/code&gt; is R + 0.9 x (256Mi - R), so it sits between 230.4 MiB and 256 MiB. Our runs are the strictest case: a Burstable container with an explicit zero memory request on a 1.36 node with the MemoryQoS gate turned on. The &lt;code&gt;anon-fit&lt;/code&gt; rows are the ones to read for that.&lt;/p&gt;

&lt;p&gt;The KEP explains why it took so long. The 1.28 beta was cancelled because a workload "stays stuck indefinitely" at &lt;code&gt;memory.high&lt;/code&gt;, and the KEP says kernel 5.9 fixed that livelock. On 6.8 our warm runs did make progress, slowly. The own-code runs barely did. The 1.37 beta turns the gate on but leaves &lt;code&gt;memoryThrottlingFactor&lt;/code&gt; unset, so upgrading does not add &lt;code&gt;memory.high&lt;/code&gt; to any container. If you set the factor to 0.9, you opt into the &lt;code&gt;both&lt;/code&gt; rows above.&lt;/p&gt;

&lt;p&gt;The two Kubernetes posts this week cover the other side of the same cgroups: &lt;a href="https://dev.to/devopsdaily/an-empty-resources-makes-kubernetes-134-to-136-pods-besteffort-2p15"&gt;an empty &lt;code&gt;resources: {}&lt;/code&gt; makes pods BestEffort&lt;/a&gt; on 1.34 to 1.36 and changes their &lt;code&gt;oom_score_adj&lt;/code&gt; and cgroup slice, and &lt;a href="https://dev.to/devopsdaily/kubernetes-137-garhwal-what-shipped-and-what-slipped-1cmi"&gt;the 1.37 review&lt;/a&gt; lists what else moved in that release.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One host, one kernel, no swap.&lt;/strong&gt; Everything here is Ubuntu 6.8.0-147 on a 4 vCPU machine with no swap. With swap or zswap, reclaim can move anonymous pages out, so &lt;code&gt;memory.high&lt;/code&gt; can hold a cgroup near the limit instead of only slowing it, and &lt;code&gt;memory.max&lt;/code&gt; reaches the OOM killer later. We did not test that. Newer kernels have changed the memcg charge code, and we did not test them either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What empties the precharge.&lt;/strong&gt; We measured how many sleeps a task paid, and we read in the source what makes a new charge necessary. We did not trace which event emptied the per-CPU precharge each time. Our sampler on the same CPU made it happen on almost every long sleep. Without it, the phases came and went with the host's other work, which we did not control. An idle machine, or one with more CPUs, could pay fewer sleeps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rest of the extra per sleep.&lt;/strong&gt; The timer wheel's rounding explains about half of the 6% to 10%. We did not split the rest into reclaim time, wake-up delay and our own probes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the own-code thrash ends.&lt;/strong&gt; Under &lt;code&gt;memory.max&lt;/code&gt; it was not killed in 20 seconds in the matrix (one smoke run was killed at 21.7 seconds, another was still running at 70). We stopped these runs early because they read about 100 MB/s from a shared disk, so we cannot say how long a real container would thrash before the kill. The result also depends on disk speed, which we changed with &lt;code&gt;io.max&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run order and a shared host.&lt;/strong&gt; Every repetition ran the configurations in the same order, on a machine with other services. Background load can move a single run either way. The three repetitions agree closely, but we did not randomize the order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real programs.&lt;/strong&gt; Our workload is a Python loop that touches memory in a fixed pattern. A JVM, a Go program or a database touches memory differently, and the &lt;code&gt;MAP_POPULATE&lt;/code&gt; run shows that the touch pattern alone changed the cost 32 times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 4-process numbers per operation.&lt;/strong&gt; &lt;code&gt;memory.events&lt;/code&gt; counts the whole cgroup, so in the 4-process run one operation's rise in &lt;code&gt;high&lt;/code&gt; includes the other processes' sleeps. We used that run only for growth, not for sleeps per operation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes on a real node.&lt;/strong&gt; We wrote the same &lt;code&gt;memory.high&lt;/code&gt; value the kubelet would write, but we did not run a kubelet. The Kubernetes section is from the source at v1.36.6 and v1.37.2.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Without swap, do not use &lt;code&gt;memory.high&lt;/code&gt; as the only limit.&lt;/strong&gt; It does not stop growth. It slows new allocations, and a program that keeps allocating stays slow as long as it stays over the limit. The kernel never kills it for &lt;code&gt;memory.high&lt;/code&gt;, so add &lt;code&gt;memory.max&lt;/code&gt; or a pressure-based killer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you set both, leave room.&lt;/strong&gt; With &lt;code&gt;memory.high&lt;/code&gt; at 90% of &lt;code&gt;memory.max&lt;/code&gt;, a program that fits between the two ran about 750 times slower per allocation on a CPU apart from our sampler, and about 24,000 times slower next to it. Apart from the sampler it finished in 2 of 3 runs within 120 seconds; next to it, none finished within 150 seconds. Measure your program's peak first. Put &lt;code&gt;memory.high&lt;/code&gt; above the normal working set, not at a fixed fraction of the limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not plan with the formula alone.&lt;/strong&gt; It gives the length of one sleep. How many sleeps a task pays depends on what else allocates memory on the same CPU, by up to 64 times, so test on a host that is as busy as production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read &lt;code&gt;memory.events&lt;/code&gt; and &lt;code&gt;memory.pressure&lt;/code&gt;, not only &lt;code&gt;memory.current&lt;/code&gt;.&lt;/strong&gt; A throttled cgroup looks healthy by memory use: it sits a few MiB over its limit and uses no CPU. The &lt;code&gt;high&lt;/code&gt; counter and memory &lt;code&gt;some&lt;/code&gt; near 100% show it at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch &lt;code&gt;io.pressure&lt;/code&gt; and &lt;code&gt;pgsteal&lt;/code&gt; on cgroups at their limit.&lt;/strong&gt; Thrashing on a program's own code showed up there, not in memory pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Kubernetes 1.37, MemoryQoS is on but does not throttle until you set &lt;code&gt;memoryThrottlingFactor&lt;/code&gt;.&lt;/strong&gt; If you set it, test your Burstable pods whose usage sits between the factor and the limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scripts, the raw output of every run, and the claim we wrote before the first run are in the repository above.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/cgroup-v2-memory-high-vs-memory-max-measured" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>cgroups</category>
      <category>memory</category>
      <category>systemd</category>
    </item>
    <item>
      <title>Alpine OpenSSH Password Bypass (CVE-2026-123158): Check and Fix</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 05 Nov 2026 21:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/alpine-openssh-password-bypass-cve-2026-123158-check-and-fix-fj</link>
      <guid>https://dev.to/devopsdaily/alpine-openssh-password-bypass-cve-2026-123158-check-and-fix-fj</guid>
      <description>&lt;p&gt;If you run sshd on Alpine Linux, upgrade openssh and restart sshd now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apk upgrade &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; rc-service sshd restart

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 2026-11-05, Alpine published fixed &lt;code&gt;openssh&lt;/code&gt; packages for every branch from 3.16 to edge, and OpenSSH released 10.6p2. The bug is &lt;strong&gt;CVE-2026-123158&lt;/strong&gt;. On an affected host, a person who can reach the SSH port can log in to an account that has no password, without a password. The cause, in one sentence: when sshd is built without PAM, an error value from musl's &lt;code&gt;crypt()&lt;/code&gt; could be treated as a match for accounts that have no password.&lt;/p&gt;

&lt;p&gt;This post tells you how to find out if a host or a container is affected, how to fix it, and what to look for in your logs. It does not show how to trigger the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You are affected only if four conditions are all true. In our tests, a fresh Alpine container with &lt;code&gt;openssh-server&lt;/code&gt; failed two of them.&lt;/li&gt;
&lt;li&gt;The fix is &lt;code&gt;apk upgrade -U -a&lt;/code&gt;, then a restart of sshd. The upgrade alone does not replace the running daemon.&lt;/li&gt;
&lt;li&gt;If you cannot upgrade, set &lt;code&gt;PasswordAuthentication no&lt;/code&gt; and restart sshd.&lt;/li&gt;
&lt;li&gt;Our read-only script checks the four conditions, the package version and the restart for a host or for every running container.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Root on the Alpine host, or access to &lt;code&gt;docker exec&lt;/code&gt; for containers&lt;/li&gt;
&lt;li&gt;A second SSH session open before you change sshd settings&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who is affected
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://alpinelinux.org/posts/2026-11-05-openssh-password-authentication-bypass/" rel="noopener noreferrer"&gt;Alpine advisory&lt;/a&gt; says you are affected only if all of these are true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;sshd is built without PAM.&lt;/strong&gt; The &lt;code&gt;openssh-server&lt;/code&gt; package is. The &lt;code&gt;openssh-server-pam&lt;/code&gt; package is affected too if &lt;code&gt;UsePAM&lt;/code&gt; is &lt;code&gt;no&lt;/code&gt;, which is the OpenSSH default when the config does not set it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PasswordAuthentication yes&lt;/code&gt;&lt;/strong&gt; , which is the OpenSSH default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An account has &lt;code&gt;*&lt;/code&gt; in its password field.&lt;/strong&gt; On Alpine base images root is &lt;code&gt;root:*&lt;/code&gt;, but the default &lt;code&gt;PermitRootLogin prohibit-password&lt;/code&gt; blocks password logins for root. Key-only users, the &lt;code&gt;alpine&lt;/code&gt; user on tiny-cloud images, and directory users through &lt;code&gt;musl-nscd&lt;/code&gt; (they have no shadow entry) also count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At least one local account has a &lt;code&gt;$5$&lt;/code&gt; or &lt;code&gt;$6$&lt;/code&gt; hash.&lt;/strong&gt; One user with a password is enough.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You are not affected if &lt;code&gt;PasswordAuthentication&lt;/code&gt; is &lt;code&gt;no&lt;/code&gt;, if you use &lt;code&gt;openssh-server-pam&lt;/code&gt; with &lt;code&gt;UsePAM yes&lt;/code&gt;, if no account has a &lt;code&gt;$&lt;/code&gt;-style hash, or if your containers do not run sshd. Locked accounts (&lt;code&gt;!&lt;/code&gt; in the password field) are not affected. Instances where cloud-init sets &lt;code&gt;PasswordAuthentication no&lt;/code&gt; are not affected.&lt;/p&gt;

&lt;p&gt;These are the fixed &lt;code&gt;openssh&lt;/code&gt; versions from the advisory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Fixed version&lt;/th&gt;
&lt;th&gt;Branch&lt;/th&gt;
&lt;th&gt;Fixed version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;edge&lt;/td&gt;
&lt;td&gt;10.6_p2-r0&lt;/td&gt;
&lt;td&gt;3.20&lt;/td&gt;
&lt;td&gt;9.7_p1-r6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.24&lt;/td&gt;
&lt;td&gt;10.3_p1-r2&lt;/td&gt;
&lt;td&gt;3.19&lt;/td&gt;
&lt;td&gt;9.6_p1-r3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.23&lt;/td&gt;
&lt;td&gt;10.2_p1-r1&lt;/td&gt;
&lt;td&gt;3.18&lt;/td&gt;
&lt;td&gt;9.3_p2-r4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.22&lt;/td&gt;
&lt;td&gt;10.0_p1-r11&lt;/td&gt;
&lt;td&gt;3.17&lt;/td&gt;
&lt;td&gt;9.1_p1-r7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.21&lt;/td&gt;
&lt;td&gt;9.9_p2-r1&lt;/td&gt;
&lt;td&gt;3.16&lt;/td&gt;
&lt;td&gt;9.0_p1-r6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Check one host
&lt;/h2&gt;

&lt;p&gt;We wrote a small POSIX &lt;code&gt;sh&lt;/code&gt; script that reads these facts and changes nothing:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/alpine-sshd-check" rel="noopener noreferrer"&gt;The-DevOps-Daily/alpine-sshd-check on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It reads the branch from &lt;code&gt;/etc/alpine-release&lt;/code&gt;, the installed package version from &lt;code&gt;apk&lt;/code&gt;, the effective settings from &lt;code&gt;sshd -T&lt;/code&gt;, and whether the running sshd is still the sshd binary on disk. From &lt;code&gt;/etc/shadow&lt;/code&gt; it reads only the type of each password field: it counts &lt;code&gt;*&lt;/code&gt; fields and &lt;code&gt;$5$&lt;/code&gt;/&lt;code&gt;$6$&lt;/code&gt; fields. It never prints a hash or a user name, and it does not connect to sshd. Because it runs as root, it executes only the packaged sshd binaries in &lt;code&gt;/usr/sbin&lt;/code&gt;, and only if root owns them and no one else can write to them. If openssh can come from edge on a stable host (an untagged edge repository, or a pin such as &lt;code&gt;openssh-server@edge&lt;/code&gt;), it compares against the edge fix, and it says "cannot tell" when it cannot find where the package came from. Exit codes are &lt;code&gt;0&lt;/code&gt; not affected, &lt;code&gt;1&lt;/code&gt; affected, &lt;code&gt;2&lt;/code&gt; fixed but restart needed, &lt;code&gt;3&lt;/code&gt; cannot tell. Add &lt;code&gt;--json&lt;/code&gt; for machine-readable output.&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="nb"&gt;sudo &lt;/span&gt;sh check.sh

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We ran it in throwaway containers on Alpine 3.21, 3.22, 3.23, 3.24 and edge after &lt;code&gt;apk add openssh-server&lt;/code&gt;. sshd ran with &lt;code&gt;--network none&lt;/code&gt;, so no port was open to anyone. Every branch installed the fixed version, and every run exited &lt;code&gt;0&lt;/code&gt;. The run below is the closest one to an affected host: Alpine 3.24 with a key-only user set to &lt;code&gt;*&lt;/code&gt; and one user with a random password, so all four conditions are true. The version is the only thing that keeps it safe:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;check.sh in the Alpine 3.24 test container&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# all four conditions true, fixed package installed&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; asc-users sh &lt;span class="nt"&gt;-s&lt;/span&gt; &amp;lt; check.sh
alpine-sshd-check: CVE-2026-123158
Alpine release: 3.24.4 &lt;span class="o"&gt;(&lt;/span&gt;branch 3.24&lt;span class="o"&gt;)&lt;/span&gt;
sshd binary: /usr/sbin/sshd &lt;span class="o"&gt;(&lt;/span&gt;package openssh-server&lt;span class="o"&gt;)&lt;/span&gt;
Installed openssh: 10.3_p1-r2
Fixed &lt;span class="k"&gt;in&lt;/span&gt;: 10.3_p1-r2 &lt;span class="o"&gt;(&lt;/span&gt;installed is fixed: &lt;span class="nb"&gt;yes&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;1] non-PAM sshd: &lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;2] PasswordAuthentication &lt;span class="nb"&gt;yes&lt;/span&gt;: &lt;span class="nb"&gt;yes&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;3] account with &lt;span class="s1"&gt;'*'&lt;/span&gt; that can use a password login: &lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;2 &lt;span class="s1"&gt;'*'&lt;/span&gt; accounts, 1 usable&lt;span class="p"&gt;;&lt;/span&gt; root is &lt;span class="s1"&gt;'*'&lt;/span&gt;: &lt;span class="nb"&gt;yes&lt;/span&gt;, PermitRootLogin prohibit-password&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;4] &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nv"&gt;$5$/$6$ &lt;/span&gt;&lt;span class="nb"&gt;hash&lt;/span&gt;: &lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;1 found&lt;span class="o"&gt;)&lt;/span&gt;
sshd running: &lt;span class="nb"&gt;yes&lt;/span&gt;, started 2026-11-05T21:49:52Z, binary on disk changed 2026-11-05T21:49:35Z, restart needed: no
note: root is &lt;span class="s1"&gt;'*'&lt;/span&gt; but PermitRootLogin is &lt;span class="s1"&gt;'prohibit-password'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; root counts only with PermitRootLogin &lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;an account that can doas to root still matters&lt;span class="o"&gt;)&lt;/span&gt;
Result: NOT AFFECTED: fixed openssh installed &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="o"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things we found while we ran it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On 3.24, &lt;code&gt;adduser -D deploy&lt;/code&gt; wrote &lt;code&gt;!&lt;/code&gt; (locked), not &lt;code&gt;*&lt;/code&gt;. A locked account is not affected, but it cannot log in with a key either. If you changed key-only users to &lt;code&gt;*&lt;/code&gt; so that key login works, those are the accounts that matter.&lt;/li&gt;
&lt;li&gt;OpenSSH 10.6 prints &lt;code&gt;sshd -T&lt;/code&gt; keywords in mixed case (&lt;code&gt;PasswordAuthentication yes&lt;/code&gt;). Older versions print lower case. The first version of our script matched only lower case and reported "unknown" on edge. Use &lt;code&gt;grep -i&lt;/code&gt; in your own checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check containers
&lt;/h2&gt;

&lt;p&gt;Containers that do not run sshd are not affected. To find the ones that do, &lt;code&gt;scan-containers.sh&lt;/code&gt; lists the running containers, skips those that are not Alpine or have no sshd process, and pipes &lt;code&gt;check.sh&lt;/code&gt; into the rest with &lt;code&gt;docker exec -i ... sh -s&lt;/code&gt;. Nothing is copied into the container. Because it runs each container's own &lt;code&gt;sh&lt;/code&gt; as root inside that container, scan only containers whose images you trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;scan-containers.sh&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sh scan-containers.sh
CONTAINER IMAGE ALPINE INSTALLED FIXED EXIT RESULT
asc-users asc-demo:3.24 3.24.4 10.3_p1-r2 10.3_p1-r2 0 not affected
asc-pa-no asc-demo:3.24 3.24.4 10.3_p1-r2 10.3_p1-r2 0 not affected
asc-edge asc-demo:edge 3.25.0_alpha20260805 10.6_p2-r0 10.6_p2-r0 0 not affected
asc-3.24 asc-demo:3.24 3.24.4 10.3_p1-r2 10.3_p1-r2 0 not affected
asc-3.23 asc-demo:3.23 3.23.8 10.2_p1-r1 10.2_p1-r1 0 not affected
asc-3.22 asc-demo:3.22 3.22.8 10.0_p1-r11 10.0_p1-r11 0 not affected
asc-3.21 asc-demo:3.21 3.21.10 9.9_p2-r1 9.9_p2-r1 0 not affected

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you build images that install &lt;code&gt;openssh-server&lt;/code&gt;, a rebuild can reuse a cached &lt;code&gt;apk add&lt;/code&gt; layer with the old package. Rebuild with &lt;code&gt;docker build --pull --no-cache&lt;/code&gt;, then replace the running containers. A restart of the old container does not change its package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it, and restart sshd
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apk upgrade &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;
rc-service sshd restart

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The restart is not optional. The package upgrade replaces the files on disk, but it does not restart the sshd that is already running, and the advisory says to restart it after the upgrade. &lt;code&gt;check.sh&lt;/code&gt; checks whether the running sshd is still the same file as the sshd binary on disk. An upgrade writes a new file, so a daemon that was not restarted is a different file, and the script exits &lt;code&gt;2&lt;/code&gt;. Run it again after the restart.&lt;/p&gt;

&lt;p&gt;On edge, the fix comes with OpenSSH 10.6p2. If you jump from an older 10.x, read our post on &lt;a href="https://devops-daily.com/posts/openssh-10-6-domain-usernames-and-compression" rel="noopener noreferrer"&gt;what OpenSSH 10.6 changed&lt;/a&gt; first: it refuses some usernames and changes &lt;code&gt;ssh -C&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workaround: turn off password logins
&lt;/h2&gt;

&lt;p&gt;If you cannot upgrade now, turn off password authentication. Either step closes the hole.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Make sure that key login works first. Keep your current session open, and test a new key login in a second session before you close it.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/ssh/sshd_config.d
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PasswordAuthentication no'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/ssh/sshd_config.d/00-no-passwords.conf
sshd &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; rc-service sshd restart
sshd &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; passwordauthentication &lt;span class="c"&gt;# must print: passwordauthentication no&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alpine's default &lt;code&gt;sshd_config&lt;/code&gt; reads &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt; from 3.19 onward. On 3.18 and older, change the line in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; itself; the advisory has a &lt;code&gt;sed&lt;/code&gt; command for that. A &lt;code&gt;Match&lt;/code&gt; block can turn password logins back on for some users or addresses, and &lt;code&gt;sshd -T&lt;/code&gt; without &lt;code&gt;-C&lt;/code&gt; does not show that, so read your &lt;code&gt;Match&lt;/code&gt; blocks too. In our 3.24 test container, the drop-in changed condition 2 to &lt;code&gt;no&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tiny-cloud alpine user
&lt;/h2&gt;

&lt;p&gt;The official tiny-cloud images (qcow2, VHD and AMI) from 3.18.11 to 3.24.3 have an &lt;code&gt;alpine&lt;/code&gt; user with &lt;code&gt;*&lt;/code&gt; in the password field and passwordless &lt;code&gt;doas&lt;/code&gt;. They are safe on first boot. They become exposed as soon as any &lt;code&gt;$6$&lt;/code&gt; hash is on the machine, for example when an admin sets a console password or user-data adds a user with &lt;code&gt;hashed_passwd&lt;/code&gt;. Then an attacker can get a shell as &lt;code&gt;alpine&lt;/code&gt; and use &lt;code&gt;doas&lt;/code&gt; to become root.&lt;/p&gt;

&lt;p&gt;Images from 3.21.10, 3.22.8, 3.23.8 and 3.24.4 contain the fixed package. Instances that started from older images do not get the fix from a new image: upgrade openssh on each one, or turn off password logins.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to look for in logs
&lt;/h2&gt;

&lt;p&gt;Look for password logins to accounts that have no password. On a host with syslog, that is &lt;code&gt;/var/log/messages&lt;/code&gt;. For a container that runs &lt;code&gt;sshd -e&lt;/code&gt;, it is &lt;code&gt;docker logs&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="c"&gt;# accounts with '*' in the password field (names only)&lt;/span&gt;
&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'$2 == "*" { print $1 }'&lt;/span&gt; /etc/shadow

&lt;span class="c"&gt;# password logins; compare the user names with the list above&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'sshd.*Accepted password for'&lt;/span&gt; /var/log/messages
docker logs &amp;lt;container&amp;gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Accepted password for'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any &lt;code&gt;Accepted password for alpine&lt;/code&gt;, for &lt;code&gt;root&lt;/code&gt;, or for a key-only user is a bad sign. On exposed machines, also review &lt;code&gt;doas&lt;/code&gt; use, new users, new files, and changes to &lt;code&gt;authorized_keys&lt;/code&gt; files. If you cannot rule out such a login, treat the machine as compromised and rebuild it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not check
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A vulnerable package or a real upgrade.&lt;/strong&gt; Alpine replaced the packages on every branch the same day, so each fresh install got the fixed version. Exit codes &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;2&lt;/code&gt; are simulations in the repo's &lt;code&gt;test-exit-codes.sh&lt;/code&gt;: a copy of the script with a raised fixed version, and the same sshd binary renamed over &lt;code&gt;/usr/sbin/sshd&lt;/code&gt; while sshd runs. Both gave the expected code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;openssh-server-pam&lt;/code&gt;, &lt;code&gt;musl-nscd&lt;/code&gt; users, tiny-cloud images and branches 3.16 to 3.20.&lt;/strong&gt; We did not measure them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other musl systems.&lt;/strong&gt; The &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-123158" rel="noopener noreferrer"&gt;CVE record&lt;/a&gt; covers sshd before 10.6p2 on Linux with musl and without PAM, and names Alpine as the example. We did not check other musl-based distributions or firmware builds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sources: the &lt;a href="https://alpinelinux.org/posts/2026-11-05-openssh-password-authentication-bypass/" rel="noopener noreferrer"&gt;Alpine advisory&lt;/a&gt;, the &lt;a href="https://www.openssh.com/releasenotes.html#10.6p2" rel="noopener noreferrer"&gt;OpenSSH 10.6p2 release notes&lt;/a&gt; and the &lt;a href="https://www.cve.org/CVERecord?id=CVE-2026-123158" rel="noopener noreferrer"&gt;CVE record&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/alpine-openssh-password-bypass" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>linux</category>
      <category>openssh</category>
      <category>ssh</category>
    </item>
    <item>
      <title>OpenTelemetry Collector Custom Builds: 6.5x Smaller, and What Resource Detectors Cost at Startup</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Thu, 05 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/opentelemetry-collector-custom-builds-65x-smaller-and-what-resource-detectors-cost-at-startup-4fla</link>
      <guid>https://dev.to/devopsdaily/opentelemetry-collector-custom-builds-65x-smaller-and-what-resource-detectors-cost-at-startup-4fla</guid>
      <description>&lt;p&gt;The contrib release of the OpenTelemetry Collector 0.164.0 is a 420.3 MB binary on linux/amd64. We built a Collector with the &lt;strong&gt;OpenTelemetry Collector Builder (ocb)&lt;/strong&gt; that has only the 10 components a VM agent uses and only the &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;system&lt;/code&gt; resource detectors. It is 64.3 MB, so contrib is 6.5 times larger. After 10 seconds idle, the custom build held 45.1 MiB of resident memory and contrib held 199.8 MiB. The anonymous part (heap, stacks and runtime data) was 8.0 MiB against 34.8 MiB.&lt;/p&gt;

&lt;p&gt;The binary adds a little to startup: contrib needed 110 to 160 ms more than the small build on the droplet, and 300 to 500 ms more on the Pi. The detector list in your config can add seconds. We started both builds with cloud detectors for clouds the host was not on, in four network setups. In every list without &lt;code&gt;akamai&lt;/code&gt;, each start waited for the slowest detector. When that detector got no answer, it waited for the whole &lt;code&gt;timeout&lt;/code&gt;, which is 5 s by default. That happened on a Raspberry Pi with no metadata service. With a list of 13 cloud detectors it also happened on a DigitalOcean droplet, because two of them ask addresses that DigitalOcean does not answer. The detectors run in parallel, so where nothing answered, the list with 3 cloud detectors and the list with 13 cost the same 5 s.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;timeout: 1s&lt;/code&gt; cut the wait to about 1 s in most cases. Two detectors do not obey it. &lt;code&gt;digitalocean&lt;/code&gt; ignores the deadline, so where nothing answers it waits its own 2 s. &lt;code&gt;akamai&lt;/code&gt; makes its network call while the processor is created. In all four setups we tested, none of them on Akamai, the Collector did not start. It exited at once where the request got an answer or a fast error, and after 30 s where it got no answer.&lt;/p&gt;

&lt;p&gt;On 29 October the OpenTelemetry blog showed &lt;a href="https://opentelemetry.io/blog/2026/collector-with-less-dependencies/" rel="noopener noreferrer"&gt;how to compile detectors out with ocb&lt;/a&gt; and reported a Kubernetes distribution about 20% smaller. This post measures three things that page does not cover. What does the same filter do to a small build? What does the build do to memory at idle? And what does the detector list cost at startup on hosts that are not on the clouds the config names? Every number below comes from a file in the companion repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Size.&lt;/strong&gt; On amd64, contrib is 420.3 MB, the core &lt;code&gt;otelcol&lt;/code&gt; release is 183.8 MB, our agent with every detector is 124.1 MB, and the same agent with only &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;system&lt;/code&gt; is 64.3 MB. On this small build, the detector filter alone removed 48% of the binary and 64 Go modules: the AWS SDK, the Google Cloud clients, the Docker client, the Consul API and four more cloud metadata clients. arm64 gives the same picture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory.&lt;/strong&gt; After 10 s idle: contrib 199.8 MiB RSS, core 98.4 MiB, our agent 61.5 MiB, the agent without cloud detectors 45.1 MiB. About four fifths of each number is file-backed memory (&lt;code&gt;RssFile&lt;/code&gt;), which grows with the binary. The anonymous part (&lt;code&gt;RssAnon&lt;/code&gt;) was 34.8 MiB for contrib and 8.0 MiB for the small build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Startup with no cloud detectors:&lt;/strong&gt; about 190 to 220 ms for contrib and 45 to 85 ms for the small agents on the droplet, and 480 to 610 ms against 110 to 270 ms on the Pi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Startup with cloud detectors for other clouds:&lt;/strong&gt; &lt;code&gt;[env, system, ec2, gcp, azure]&lt;/code&gt; held startup for the full 5 s timeout where 169.254.169.254 got no answer. Where the connect failed at once, it held startup for 4.1 s, because the AWS SDK retries the connect. On the droplet those three cost under 4 ms each, but the 13-cloud list still took 5.1 s there, because &lt;code&gt;alibaba_ecs&lt;/code&gt; and &lt;code&gt;scaleway&lt;/code&gt; ask other addresses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The timeout setting&lt;/strong&gt; bounds the wait: 1 s, 5 s or 10 s plus normal startup. The exceptions are &lt;code&gt;digitalocean&lt;/code&gt;, which waits its own 2 s whatever you set when nothing answers, and &lt;code&gt;akamai&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No start at all.&lt;/strong&gt; &lt;code&gt;akamai&lt;/code&gt; in the list: exit 1 in all four setups, after 30 s in the two with no answer. &lt;code&gt;fail_on_missing_metadata: true&lt;/code&gt;: exit 1 after the 5 s timeout. A detector that ocb compiled out but the config still names: &lt;code&gt;validate&lt;/code&gt; passes, then a nil pointer panic at start. The processor's name for a detector in the ocb manifest (&lt;code&gt;ec2&lt;/code&gt; instead of &lt;code&gt;aws_ec2&lt;/code&gt;): the build stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What to do.&lt;/strong&gt; List only the detectors for the platform the host runs on, and build with the same list in &lt;code&gt;dist.resource.detection.detectors&lt;/code&gt;. If one config must cover several clouds, keep the list short, set &lt;code&gt;timeout&lt;/code&gt; to 1 or 2 s, and leave out &lt;code&gt;akamai&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;An x86_64 Linux host for the builds, with about 3 GB of free disk. ocb cross-compiles the arm64 binaries there.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;python3&lt;/code&gt; and &lt;code&gt;ip&lt;/code&gt; (iproute2). Root only for the two network namespaces.&lt;/li&gt;
&lt;li&gt;An arm64 Linux host if you want the second architecture. We used a Raspberry Pi with 4 cores and less than 4 GB of memory.&lt;/li&gt;
&lt;li&gt;About an hour for a full run. The cases that fail after 30 s take most of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we built and where we ran it
&lt;/h2&gt;

&lt;p&gt;We downloaded the official 0.164.0 releases of &lt;code&gt;otelcol-contrib&lt;/code&gt; and &lt;code&gt;otelcol&lt;/code&gt; for both architectures and checked each tarball against its published &lt;code&gt;.sha256&lt;/code&gt;. Then ocb 0.164.0 built two versions of one small agent. It has the components a VM agent uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;receivers: OTLP, hostmetrics and filelog&lt;/li&gt;
&lt;li&gt;processors: batch, memory_limiter and resource_detection&lt;/li&gt;
&lt;li&gt;exporters: debug, OTLP and OTLP/HTTP&lt;/li&gt;
&lt;li&gt;extension: health_check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;otelcol-agent&lt;/code&gt; uses the ocb default, so every detector is compiled in. &lt;code&gt;otelcol-agent-envsys&lt;/code&gt; adds the ocb setting that compiles out every detector you do not list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# manifests/agent-envsys.yaml (excerpt, comment on detectors added)&lt;/span&gt;
&lt;span class="na"&gt;dist&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;otelcol-agent-envsys&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.164.0&lt;/span&gt;
  &lt;span class="na"&gt;build_tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;grpcnotrace'&lt;/span&gt; &lt;span class="c1"&gt;# same tag as the contrib release manifest&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;detection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;detectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# ocb 0.163.1 and later: every detector not listed gets an omit_detector_&amp;lt;name&amp;gt; build tag&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;system&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;go version -m&lt;/code&gt; on the release binaries reports go1.26.10, so we pinned the same Go for our builds. &lt;code&gt;go version -m&lt;/code&gt; and &lt;code&gt;file&lt;/code&gt; show &lt;code&gt;-trimpath=true&lt;/code&gt;, &lt;code&gt;CGO_ENABLED=0&lt;/code&gt; and stripped symbols for every binary (&lt;code&gt;results/buildinfo/&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Every start ran the same pipeline: OTLP in, batch, debug out. The harness gave each start a clean environment with only &lt;code&gt;PATH&lt;/code&gt; and an empty &lt;code&gt;HOME&lt;/code&gt;. No &lt;code&gt;OTEL_*&lt;/code&gt; or &lt;code&gt;AWS_*&lt;/code&gt; variable and no &lt;code&gt;~/.aws&lt;/code&gt; file could change what a detector found. The Pi runs also set &lt;code&gt;TZ=UTC&lt;/code&gt;, so their logs use UTC like the droplet's; the droplet runs used an earlier revision of the harness without it. Only the &lt;code&gt;resource_detection&lt;/code&gt; block changed between configs. Each case ran 5 times in a shuffled order, after one warm-up start per binary that we left out of the numbers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DigitalOcean droplet&lt;/strong&gt; 4 vCPU, 8 GB, amd64

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;host network&lt;/strong&gt; metadata server answers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;blackhole namespace&lt;/strong&gt; packets dropped&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;noroute namespace&lt;/strong&gt; connect fails at once&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raspberry Pi&lt;/strong&gt; 4 cores, under 4 GB, arm64

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;home network&lt;/strong&gt; router drops 169.254.169.254&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The four network setups matter more than the two architectures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;What a request to 169.254.169.254 gets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Droplet host&lt;/td&gt;
&lt;td&gt;DigitalOcean's metadata server answers. The EC2 and Azure paths return 404 in about 1 ms. This is the "host on another cloud" case.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blackhole namespace&lt;/td&gt;
&lt;td&gt;The default route goes to a dummy device, which discards every packet, so a connect gets no answer. DNS fails at once, because nothing listens on 127.0.0.53 inside the namespace.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noroute namespace&lt;/td&gt;
&lt;td&gt;Loopback only. A connect fails at once with "network is unreachable".&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raspberry Pi&lt;/td&gt;
&lt;td&gt;The home router drops the packets. DNS works. This is the plain "not on any cloud" host.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/otelcol-ocb-vs-contrib" rel="noopener noreferrer"&gt;The-DevOps-Daily/otelcol-ocb-vs-contrib on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Size: what ocb removes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Binary size, linux/amd64, OpenTelemetry Collector 0.164.0&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;otelcol-contrib (release)&lt;/td&gt;
&lt;td&gt;420.3 MB&lt;/td&gt;
&lt;td&gt;release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;otelcol core (release)&lt;/td&gt;
&lt;td&gt;183.8 MB&lt;/td&gt;
&lt;td&gt;release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent, all detectors&lt;/td&gt;
&lt;td&gt;124.1 MB&lt;/td&gt;
&lt;td&gt;custom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent, env + system only&lt;/td&gt;
&lt;td&gt;64.3 MB&lt;/td&gt;
&lt;td&gt;custom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;results/sizes.tsv. Stripped, -trimpath, CGO_ENABLED=0, go1.26.10. The two agents have the same 10 components; only the compiled-in detectors differ.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The contrib manifest lists 246 components and 9 config providers, and the binary links 1,088 Go modules. The core release is not small either. Its 30 components include the Kafka and Prometheus receivers and exporters, and it also ships &lt;code&gt;resource_detection&lt;/code&gt; with every detector. Our agent with every detector links 289 modules, and the detector filter takes it to 225.&lt;/p&gt;

&lt;p&gt;The filter saves more on a small build than on a big one. The OpenTelemetry blog measured the Kubernetes distribution and got 182 MB to 147 MB with 13 fewer dependencies. On our agent it removed 59.8 MB of 124.1 MB (48%) on amd64, 52.9 MB of 112.7 MB (47%) on arm64, and 64 modules. A big distribution needs most of those SDKs for other components, so removing the detectors does not remove the SDKs. In a small agent, nothing else needs them. &lt;code&gt;results/deps/removed-by-detector-filter-amd64.txt&lt;/code&gt; lists the 64: 15 &lt;code&gt;aws-sdk-go-v2&lt;/code&gt; and &lt;code&gt;smithy-go&lt;/code&gt; modules, the Google Cloud compute, auth and API clients, the Docker (moby) client, the Consul API with its HashiCorp libraries, and the Hetzner, Scaleway, Linode and DigitalOcean clients.&lt;/p&gt;

&lt;p&gt;For containers, the official &lt;code&gt;otel/opentelemetry-collector-contrib:0.164.0&lt;/code&gt; image is 116.1 MB compressed on amd64 (&lt;code&gt;results/images.tsv&lt;/code&gt;). We did not build an image of the small agent. Its binary compresses to 20.3 MB with &lt;code&gt;gzip -6&lt;/code&gt;, so a &lt;code&gt;FROM scratch&lt;/code&gt; image would be near that, but that is an estimate, not a measurement.&lt;/p&gt;

&lt;p&gt;One build difference favours our side. The contrib manifest asks for the &lt;code&gt;grpcnotrace&lt;/code&gt; build tag and our builds use it, but the release binaries record no build tags in &lt;code&gt;go version -m&lt;/code&gt;. We built &lt;code&gt;otelcol-agent-notag&lt;/code&gt; to put a size on it: 124.23 MB against 124.11 MB, so the tag is worth 0.1%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory at idle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Resident memory after 10 s idle, droplet, amd64&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;contrib: RssAnon&lt;/td&gt;
&lt;td&gt;34.8 MiB&lt;/td&gt;
&lt;td&gt;RssAnon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contrib: RssFile&lt;/td&gt;
&lt;td&gt;165.2 MiB&lt;/td&gt;
&lt;td&gt;RssFile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;core: RssAnon&lt;/td&gt;
&lt;td&gt;18.7 MiB&lt;/td&gt;
&lt;td&gt;RssAnon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;core: RssFile&lt;/td&gt;
&lt;td&gt;79.5 MiB&lt;/td&gt;
&lt;td&gt;RssFile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent, all detectors: RssAnon&lt;/td&gt;
&lt;td&gt;11.2 MiB&lt;/td&gt;
&lt;td&gt;RssAnon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent, all detectors: RssFile&lt;/td&gt;
&lt;td&gt;50.2 MiB&lt;/td&gt;
&lt;td&gt;RssFile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent, env + system: RssAnon&lt;/td&gt;
&lt;td&gt;8 MiB&lt;/td&gt;
&lt;td&gt;RssAnon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent, env + system: RssFile&lt;/td&gt;
&lt;td&gt;37 MiB&lt;/td&gt;
&lt;td&gt;RssFile&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Median of 5 starts, /proc//status, config without resource_detection, results/summary.md. RssFile is resident file-backed memory; RssAnon is resident anonymous memory (heap, stacks, runtime).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Resident memory follows the binary size, but not one to one. Contrib held 199.8 MiB, 4.4 times the 45.1 MiB of the small agent. About four fifths of every number is &lt;code&gt;RssFile&lt;/code&gt;, resident memory backed by files. It grows with the binary: 165.2 MiB for contrib, 37.0 MiB for the small agent. That points at the binary's own pages, but we did not map them one by one. The kernel can drop clean file-backed pages under memory pressure and read them again later. &lt;code&gt;RssAnon&lt;/code&gt; is anonymous memory, which it cannot drop without swap. There contrib held 34.8 MiB against 8.0 MiB. A Go program runs the &lt;code&gt;init&lt;/code&gt; code of every package it links before &lt;code&gt;main&lt;/code&gt;, so a likely part of that difference is setup work for code that this pipeline never uses. We did not profile the heap to prove it.&lt;/p&gt;

&lt;p&gt;Detection with &lt;code&gt;[env, system]&lt;/code&gt; added little: contrib went to 205.4 MiB and the small agent to 47.8 MiB. The Raspberry Pi gave the same order on arm64: contrib 183.8 MiB with 34.7 MiB anonymous, the small agent 40.6 MiB with 8.0 MiB.&lt;/p&gt;

&lt;p&gt;We did not test what happens to &lt;code&gt;RssFile&lt;/code&gt; under memory pressure, or how a cgroup memory limit counts it. Read the &lt;code&gt;RssAnon&lt;/code&gt; column as the floor and &lt;code&gt;VmRSS&lt;/code&gt; as what &lt;code&gt;top&lt;/code&gt; shows you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Startup: the slowest detector sets the time
&lt;/h2&gt;

&lt;p&gt;Median time from exec to the log line "Everything is ready", 5 starts each, from &lt;code&gt;results/summary.md&lt;/code&gt;. Each cell is the small agent with every detector, then contrib in brackets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Droplet host&lt;/th&gt;
&lt;th&gt;Blackhole namespace&lt;/th&gt;
&lt;th&gt;Noroute namespace&lt;/th&gt;
&lt;th&gt;Raspberry Pi&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[env, system]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;79 ms (189 ms)&lt;/td&gt;
&lt;td&gt;77 ms (210 ms)&lt;/td&gt;
&lt;td&gt;83 ms (221 ms)&lt;/td&gt;
&lt;td&gt;192 ms (481 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+ ec2, gcp, azure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;81 ms (196 ms)&lt;/td&gt;
&lt;td&gt;5,088 ms (5,203 ms)&lt;/td&gt;
&lt;td&gt;4,098 ms (4,242 ms)&lt;/td&gt;
&lt;td&gt;5,172 ms (5,486 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+ 13 VM clouds, no akamai&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5,090 ms (5,213 ms)&lt;/td&gt;
&lt;td&gt;5,086 ms (5,203 ms)&lt;/td&gt;
&lt;td&gt;4,087 ms (4,227 ms)&lt;/td&gt;
&lt;td&gt;5,167 ms (5,393 ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+ akamai&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;exit 1 after 0.1 s (0.2 s)&lt;/td&gt;
&lt;td&gt;exit 1 after 30.1 s (30.2 s)&lt;/td&gt;
&lt;td&gt;exit 1 after 0.1 s (0.2 s)&lt;/td&gt;
&lt;td&gt;exit 1 after 30.2 s (30.5 s)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without a network wait, contrib needs about 110 to 160 ms more than the small agent to start on the droplet, and about 300 to 500 ms more on the Pi. That is real but small, and it matters only if you start collectors very often, for example one per short CI job.&lt;/p&gt;

&lt;p&gt;The wait comes from three things in the processor source (&lt;code&gt;processor/resourcedetectionprocessor&lt;/code&gt; at v0.164.1):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detectors run in parallel.&lt;/strong&gt; &lt;code&gt;detectResource&lt;/code&gt; starts one goroutine per detector and then reads their results in order (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/internal/resourcedetection.go#L203-L222" rel="noopener noreferrer"&gt;internal/resourcedetection.go, lines 203 to 222&lt;/a&gt;). The wait is the slowest detector, not the sum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The whole detection has one deadline: &lt;code&gt;timeout&lt;/code&gt;.&lt;/strong&gt; The default is 5 s (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/factory.go#L153-L157" rel="noopener noreferrer"&gt;factory.go, lines 153 to 157&lt;/a&gt;). With the default &lt;code&gt;retry.max_elapsed_time: 0&lt;/code&gt;, &lt;code&gt;Refresh&lt;/code&gt; puts that timeout on the context that every detector gets (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/internal/resourcedetection.go#L159-L168" rel="noopener noreferrer"&gt;internal/resourcedetection.go, lines 159 to 168&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Startup waits for it.&lt;/strong&gt; The processor's &lt;code&gt;Start&lt;/code&gt; runs the first detection and returns its error (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/resourcedetection_processor.go#L36-L40" rel="noopener noreferrer"&gt;resourcedetection_processor.go, lines 36 to 40&lt;/a&gt;). The Collector logs "Everything is ready" only after every processor has started.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The processor also records how long each detector took, in its own &lt;code&gt;otelcol_resourcedetection_detector_duration&lt;/code&gt; histogram. We read it from the Collector's Prometheus endpoint after each start. This is the 13-cloud list in the blackhole namespace:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time per detector, 13 VM cloud detectors, blackhole namespace, timeout 5s&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ec2&lt;/td&gt;
&lt;td&gt;5000.7 ms&lt;/td&gt;
&lt;td&gt;waits for timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;alibaba_ecs&lt;/td&gt;
&lt;td&gt;4999.8 ms&lt;/td&gt;
&lt;td&gt;waits for timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;azure&lt;/td&gt;
&lt;td&gt;4999.7 ms&lt;/td&gt;
&lt;td&gt;waits for timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hetzner&lt;/td&gt;
&lt;td&gt;5000.2 ms&lt;/td&gt;
&lt;td&gt;waits for timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scaleway&lt;/td&gt;
&lt;td&gt;4999.6 ms&lt;/td&gt;
&lt;td&gt;waits for timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;digitalocean&lt;/td&gt;
&lt;td&gt;2000.6 ms&lt;/td&gt;
&lt;td&gt;own 2 s timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nova&lt;/td&gt;
&lt;td&gt;2000.4 ms&lt;/td&gt;
&lt;td&gt;own 2 s timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;upcloud&lt;/td&gt;
&lt;td&gt;2000.7 ms&lt;/td&gt;
&lt;td&gt;own 2 s timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vultr&lt;/td&gt;
&lt;td&gt;2000.8 ms&lt;/td&gt;
&lt;td&gt;own 2 s timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;oraclecloud&lt;/td&gt;
&lt;td&gt;200.7 ms&lt;/td&gt;
&lt;td&gt;fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ibmcloud_vpc&lt;/td&gt;
&lt;td&gt;1.2 ms&lt;/td&gt;
&lt;td&gt;fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gcp&lt;/td&gt;
&lt;td&gt;0.9 ms&lt;/td&gt;
&lt;td&gt;fast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tencent_cvm&lt;/td&gt;
&lt;td&gt;0.7 ms&lt;/td&gt;
&lt;td&gt;fast&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Median of 5 starts of the small agent with all detectors, from otelcol_resourcedetection_detector_duration in results/runs/droplet-blackhole-extra.jsonl. Every detector reported outcome=success: "not on this cloud" is a success with no attributes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The same histogram tells you which detector held each of the other setups:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Detectors that took the longest&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Droplet host&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;alibaba_ecs&lt;/code&gt; and &lt;code&gt;scaleway&lt;/code&gt;, 5.0 s each. They ask &lt;code&gt;100.100.100.200&lt;/code&gt; and &lt;code&gt;169.254.42.42&lt;/code&gt;, and nothing on DigitalOcean answers those. The other 11 finished in 2 to 20 ms (medians).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blackhole namespace&lt;/td&gt;
&lt;td&gt;Five detectors at 5.0 s and four at 2.0 s (see the chart).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noroute namespace&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ec2&lt;/code&gt; alone, 4.0 s. Every other detector finished in under 2 ms.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raspberry Pi&lt;/td&gt;
&lt;td&gt;Six detectors at 5.0 s: the five from the blackhole namespace and &lt;code&gt;ibmcloud_vpc&lt;/code&gt;, which took 1.2 ms in the namespace. We did not trace why. The same four at 2.0 s. With working DNS, &lt;code&gt;gcp&lt;/code&gt; took 13.5 ms and &lt;code&gt;tencent_cvm&lt;/code&gt; 15.9 ms.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How each detector probes decides how long it waits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;azure&lt;/strong&gt; sends one GET with a plain &lt;code&gt;&amp;amp;http.Client{}&lt;/code&gt; that has no timeout (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/internal/metadataproviders/azure/metadata.go#L33-L38" rel="noopener noreferrer"&gt;internal/metadataproviders/azure/metadata.go, lines 33 to 38&lt;/a&gt;). When the packets get no answer, it waits for the processor's deadline. Then it returns an empty resource and no error (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/internal/azure/azure.go#L55-L63" rel="noopener noreferrer"&gt;internal/azure/azure.go, lines 55 to 63&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ec2&lt;/strong&gt; uses the AWS SDK's IMDS client: a 250 ms dial timeout, a 500 ms response header timeout (&lt;a href="https://github.com/aws/aws-sdk-go-v2/blob/feature/ec2/imds/v1.20.1/feature/ec2/imds/api_client.go#L277-L278" rel="noopener noreferrer"&gt;aws-sdk-go-v2 feature/ec2/imds v1.20.1, api_client.go, lines 277 and 278&lt;/a&gt;), and up to 3 attempts per request (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/internal/aws/ec2/config.go#L16-L19" rel="noopener noreferrer"&gt;internal/aws/ec2/config.go, lines 16 to 19&lt;/a&gt;). A connect that fails at once is retried with backoff, which is why &lt;code&gt;ec2&lt;/code&gt; took 4.0 s in the noroute namespace. With no answer at all and a 10 s timeout, it gave up by itself after 5.5 s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gcp&lt;/strong&gt; calls &lt;code&gt;metadata.OnGCEWithContext&lt;/code&gt;, which races a DNS lookup of &lt;code&gt;metadata.google.internal&lt;/code&gt; against an HTTP probe. On a host that does not look like a Google VM, it trusts the first answer (&lt;a href="https://github.com/googleapis/google-cloud-go/blob/compute/metadata/v0.10.0/compute/metadata/metadata.go#L456-L480" rel="noopener noreferrer"&gt;cloud.google.com/go/compute/metadata v0.10.0, metadata.go, lines 456 to 480&lt;/a&gt;). A fast NXDOMAIN or a fast DNS failure ends it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;digitalocean&lt;/strong&gt; ignores the context: its &lt;code&gt;Detect&lt;/code&gt; takes &lt;code&gt;_ context.Context&lt;/code&gt; (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/internal/digitalocean/digitalocean.go#L51" rel="noopener noreferrer"&gt;internal/digitalocean/digitalocean.go, line 51&lt;/a&gt;), and the DigitalOcean metadata client has a fixed 2 s timeout (&lt;a href="https://github.com/digitalocean/go-metadata/blob/e3650a3df44b/client.go#L25-L64" rel="noopener noreferrer"&gt;digitalocean/go-metadata, client.go, lines 25 and 64&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We started from this claim: on a host that is not on EC2, Google Cloud or Azure, each cloud detector adds a wait at startup. Two results would prove it wrong: no wait at all, or the same wait for 3 detectors and for 13. We got the second, so it was half wrong. The detectors do not each add a wait. During detection they overlap, and the slowest one sets the time. But a host on a cloud with a metadata server is not safe either, because not every cloud uses 169.254.169.254.&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeout setting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start to "Everything is ready" with [env, system, ec2, gcp, azure]&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;timeout: 1s&lt;/th&gt;
&lt;th&gt;timeout: 5s (default)&lt;/th&gt;
&lt;th&gt;timeout: 10s&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;contrib, blackhole namespace&lt;/td&gt;
&lt;td&gt;1238.8 ms&lt;/td&gt;
&lt;td&gt;5203.1 ms&lt;/td&gt;
&lt;td&gt;10235.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;agent, blackhole namespace&lt;/td&gt;
&lt;td&gt;1077.1 ms&lt;/td&gt;
&lt;td&gt;5087.6 ms&lt;/td&gt;
&lt;td&gt;10085.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contrib, Raspberry Pi&lt;/td&gt;
&lt;td&gt;1526.7 ms&lt;/td&gt;
&lt;td&gt;5485.5 ms&lt;/td&gt;
&lt;td&gt;10710.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contrib, noroute namespace&lt;/td&gt;
&lt;td&gt;1240.1 ms&lt;/td&gt;
&lt;td&gt;4241.6 ms&lt;/td&gt;
&lt;td&gt;4229.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contrib, droplet host&lt;/td&gt;
&lt;td&gt;209.3 ms&lt;/td&gt;
&lt;td&gt;196 ms&lt;/td&gt;
&lt;td&gt;216 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Median of 5 starts per point, results/summary.md. The droplet host answers on 169.254.169.254; the blackhole namespace and the Pi do not. The noroute namespace fails the connect at once.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Where nothing answers, the wait is the timeout plus normal startup, on both architectures. In the noroute namespace it stops at 4.1 to 4.2 s, where &lt;code&gt;ec2&lt;/code&gt; gives up. &lt;code&gt;timeout: 1s&lt;/code&gt; brought every three-cloud case that waited down to 1.1 to 1.2 s.&lt;/p&gt;

&lt;p&gt;The 13-cloud list with &lt;code&gt;timeout: 1s&lt;/code&gt; did not get there. In the blackhole namespace it started in 2,097 ms (contrib 2,234 ms), and on the Pi in 2,175 ms (contrib 2,410 ms), because &lt;code&gt;digitalocean&lt;/code&gt; took its 2 s anyway.&lt;/p&gt;

&lt;p&gt;A short timeout has a cost that we could not measure. On a real EC2, Google Cloud or Azure VM, a detector that needs more than 1 s gets cut off. It returns an empty resource with no error, and your telemetry has no cloud attributes. We did not run on those clouds, so we cannot say whether 1 s is enough there. Most examples in the processor README use &lt;code&gt;timeout: 2s&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four ways the Collector does not start
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ocb build&lt;/strong&gt; unknown detector name: stops&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;otelcol validate&lt;/strong&gt; passes a compiled-out detector&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;create processor&lt;/strong&gt; akamai fetches a token&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start: detection&lt;/strong&gt; parallel, one deadline&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Everything is ready&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  1. The akamai detector
&lt;/h3&gt;

&lt;p&gt;Of the 14 VM cloud detectors we ran, &lt;code&gt;akamai&lt;/code&gt; is the only one that waited or failed while the processor was created. Its &lt;code&gt;NewDetector&lt;/code&gt; calls &lt;code&gt;newAkamaiClient(context.Background())&lt;/code&gt; (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/internal/akamai/akamai.go#L43-L57" rel="noopener noreferrer"&gt;internal/akamai/akamai.go, lines 43 to 57&lt;/a&gt;), and the Linode metadata client fetches a token inside &lt;code&gt;NewClient&lt;/code&gt; (&lt;a href="https://github.com/linode/go-metadata/blob/v0.3.1/client.go#L88-L91" rel="noopener noreferrer"&gt;linode/go-metadata v0.3.1, client.go, lines 88 to 91&lt;/a&gt;). This happens before detection, so &lt;code&gt;timeout&lt;/code&gt; does not apply, and a failure there is a failure to build the pipeline.&lt;/p&gt;

&lt;p&gt;On the droplet, DigitalOcean's metadata server answers the token request, but not with a token, and the Collector exits at once:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;results/runs/droplet-host-extra.jsonl, otelcol-contrib, configs/akamai.yaml&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bin/linux-amd64/otelcol-contrib --config configs/akamai.yaml
Error: failed to build pipelines: failed to create "resource_detection" processor, in pipeline "traces": failed creating detector type "akamai": failed to refresh metadata token: failed to generate metadata token: no token returned from API
# exit code 1, 0.2 s after exec

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the noroute namespace it exited at once with &lt;code&gt;connect: network is unreachable&lt;/code&gt;. In the blackhole namespace and on the Pi, the token request first waited for a 30 s dial timeout. The Collector exited after 30.1 s in the namespace and 30.2 s on the Pi (contrib: 30.2 s and 30.5 s). With &lt;code&gt;timeout: 1s&lt;/code&gt; it still took 30 s.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. fail_on_missing_metadata: true
&lt;/h3&gt;

&lt;p&gt;This setting makes a cloud detector return an error when its metadata service is missing, instead of an empty resource. Retry is on by default (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/factory.go#L142-L151" rel="noopener noreferrer"&gt;factory.go, lines 142 to 151&lt;/a&gt;), so the processor retries the detector with backoff until &lt;code&gt;timeout&lt;/code&gt;, and then &lt;code&gt;Start&lt;/code&gt; returns the error. With &lt;code&gt;[env, system, ec2, gcp, azure]&lt;/code&gt;, the Collector exited with code 1 after 5.1 to 5.3 s in all three droplet setups. The config comment says the setting is for nodes that boot before their metadata service is ready (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/config.go#L63-L70" rel="noopener noreferrer"&gt;config.go, lines 63 to 70&lt;/a&gt;). It also turns "not on EC2" into "does not start".&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A detector that ocb compiled out
&lt;/h3&gt;

&lt;p&gt;This is the trap in the new ocb feature. &lt;code&gt;otelcol-agent-envsys&lt;/code&gt; has only &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;system&lt;/code&gt;. We gave it the three-cloud config:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;results/break/amd64-validate.txt and results/break/amd64-start.txt&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bin/linux-amd64/otelcol-agent-envsys validate --config configs/clouds3.yaml
exit code: 0
$ bin/linux-amd64/otelcol-agent-envsys --config configs/clouds3.yaml
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x130a757]

goroutine 57 [running]:
github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor/internal.(*ResourceProvider).detectWithRetry.func2()
    github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor@v0.164.1/internal/resourcedetection.go:314 +0xb7
...
exit code: 2

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In v0.164.1 each &lt;code&gt;*_omit.go&lt;/code&gt; stub returns &lt;code&gt;nil, nil&lt;/code&gt; from &lt;code&gt;NewDetector&lt;/code&gt;, for example &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/v0.164.1/processor/resourcedetectionprocessor/internal/aws/ec2/ec2_omit.go" rel="noopener noreferrer"&gt;internal/aws/ec2/ec2_omit.go&lt;/a&gt;. The processor keeps the nil detector and calls it. This is &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/52519" rel="noopener noreferrer"&gt;issue #52519&lt;/a&gt;, open when we wrote this. On &lt;code&gt;main&lt;/code&gt;, &lt;a href="https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/52063" rel="noopener noreferrer"&gt;#52063&lt;/a&gt; changes the stubs so they register no factory. It was merged after v0.164.1. With it, the processor returns &lt;code&gt;detector "ec2" is not compiled into this binary&lt;/code&gt; with the list of compiled detectors. Until a release has that change, &lt;code&gt;validate&lt;/code&gt; does not catch the mismatch, and the first sign is a crash.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The processor's names in the ocb manifest
&lt;/h3&gt;

&lt;p&gt;ocb and the processor do not use the same detector names. The processor config says &lt;code&gt;ec2&lt;/code&gt;, &lt;code&gt;nova&lt;/code&gt; and &lt;code&gt;aks&lt;/code&gt;. The ocb manifest wants the build tag names &lt;code&gt;aws_ec2&lt;/code&gt;, &lt;code&gt;openstack_nova&lt;/code&gt; and &lt;code&gt;azure_aks&lt;/code&gt; (&lt;a href="https://github.com/open-telemetry/opentelemetry-collector/blob/cmd/builder/v0.164.0/cmd/builder/internal/builder/config.go#L117-L151" rel="noopener noreferrer"&gt;cmd/builder/internal/builder/config.go, lines 117 to 151&lt;/a&gt;). This one fails early, which is the good kind of failure:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;results/build/agent-badname-amd64.log&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ocb &lt;span class="nt"&gt;--config&lt;/span&gt; manifests/agent-badname.yaml
&lt;span class="go"&gt;Error: invalid configuration: ec2: unknown resource detector

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to put in the manifest and the config
&lt;/h2&gt;

&lt;p&gt;Make the two lists match, each in its own names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ocb manifest: build tag names&lt;/span&gt;
&lt;span class="na"&gt;dist&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;detection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;detectors&lt;/span&gt;&lt;span class="pi"&gt;:&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;system&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;aws_ec2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# the processor calls this one "ec2"&lt;/span&gt;


&lt;span class="c1"&gt;# Collector config: processor names, the same detectors&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;resource_detection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;detectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;system&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ec2&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt; &lt;span class="c1"&gt;# the most a missing metadata service can add to startup&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Use one list per platform. A fleet on EC2 does not need &lt;code&gt;azure&lt;/code&gt;, and a fleet on bare metal needs neither.&lt;/li&gt;
&lt;li&gt;If one config must cover several clouds, keep the list short and set &lt;code&gt;timeout&lt;/code&gt; to 1 or 2 s. Expect &lt;code&gt;digitalocean&lt;/code&gt; to add 2 s, whatever the timeout, on hosts where its request gets no answer.&lt;/li&gt;
&lt;li&gt;Leave &lt;code&gt;akamai&lt;/code&gt; out unless the hosts run on Akamai Cloud.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;fail_on_missing_metadata&lt;/code&gt; off unless "no metadata" should mean "do not start".&lt;/li&gt;
&lt;li&gt;Use the name &lt;code&gt;resource_detection&lt;/code&gt;. In 0.164 the old name &lt;code&gt;resourcedetection&lt;/code&gt; still works, with a deprecation warning (&lt;code&gt;results/break/amd64-legacy-name.txt&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Until the #52063 change is in a release, start each new custom build once with its real config before you ship it. &lt;code&gt;validate&lt;/code&gt; is not enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On a real AWS, Google Cloud or Azure VM.&lt;/strong&gt; We had no instances there, so we did not measure how long a detector takes when it succeeds, or whether a 1 s timeout cuts it off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The container image of the small agent.&lt;/strong&gt; We report the compressed size of the official image and the gzip size of our binary. We did not build or push an image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory under pressure.&lt;/strong&gt; &lt;code&gt;RssFile&lt;/code&gt; can be reclaimed. We did not measure how much the kernel takes back, or how a cgroup limit counts it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small startup differences.&lt;/strong&gt; Each case has 5 starts. The 110 to 160 ms between contrib and the small agent repeats in every droplet setup. The Pi also ran other work, and three of its starts took 4.6 to 22 s. In all three, the time went before the Collector wrote its first log line, which points at reading the binary from the SD card, not at detection. We did not confirm that. They move the medians little, but they are in the min and max columns of &lt;code&gt;results/summary.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow DNS.&lt;/strong&gt; DNS failed at once in the namespaces and answered on the Pi, so &lt;code&gt;gcp&lt;/code&gt; and &lt;code&gt;tencent_cvm&lt;/code&gt; were fast everywhere. A host whose DNS times out would make them wait too. We did not test that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other versions.&lt;/strong&gt; Everything here is 0.164.0 and 0.164.1. The akamai and omit behaviour may change in the next release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://devops-daily.com/posts/distributed-tracing-opentelemetry-instrumentation-visualization" rel="noopener noreferrer"&gt;Distributed tracing with OpenTelemetry&lt;/a&gt; runs a Collector end to end.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://devops-daily.com/posts/opentelemetry-graduated-what-to-retire-this-quarter" rel="noopener noreferrer"&gt;OpenTelemetry graduated&lt;/a&gt; covers which vendor agents a Collector can replace.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/devopsdaily/your-trace-dies-the-moment-the-pipeline-shells-out-5g7a"&gt;Trace context in environment variables&lt;/a&gt; follows a trace through a CI pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;A custom Collector with only the components you use is 6.5 times smaller than contrib and holds under a quarter of its memory. On a small build, compiling out the unused detectors is half of the size saving. The binary costs tens to hundreds of milliseconds at startup. The detector list can cost seconds. Apart from &lt;code&gt;akamai&lt;/code&gt;, the cloud detectors you list for clouds the host is not on share one wait: the slowest probe. Where that probe gets no answer, it usually runs to the full &lt;code&gt;timeout&lt;/code&gt;, even on a host on another cloud. &lt;code&gt;digitalocean&lt;/code&gt; waits its own 2 s instead, even past a shorter timeout. Keep the list to the platforms you run on, and build with the same list. Set a short timeout if the list must stay long. Start each new binary once with its real config. Leave &lt;code&gt;akamai&lt;/code&gt; out unless you run on Akamai.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/opentelemetry-collector-custom-build-resource-detectors" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>opentelemetry</category>
      <category>observability</category>
      <category>go</category>
    </item>
    <item>
      <title>An Empty resources: {} Makes Kubernetes 1.34 to 1.36 Pods BestEffort</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 04 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/an-empty-resources-makes-kubernetes-134-to-136-pods-besteffort-2p15</link>
      <guid>https://dev.to/devopsdaily/an-empty-resources-makes-kubernetes-134-to-136-pods-besteffort-2p15</guid>
      <description>&lt;p&gt;On Kubernetes 1.34, 1.35 and 1.36, one empty block in a pod spec changes how the node treats the pod. If the pod sets &lt;code&gt;spec.resources: {}&lt;/code&gt; at pod level, the kubelet gives it QoS class &lt;strong&gt;BestEffort&lt;/strong&gt;. This happens even when every container sets requests and limits, whenever the &lt;code&gt;PodLevelResources&lt;/code&gt; feature gate is on, which is the default from 1.34. GitLab Runner's Kubernetes executor builds its job pods with that empty block. Its source does this from 18.10.0 to 19.4.3, unless the runner config sets pod-level values.&lt;/p&gt;

&lt;p&gt;We ran the same pods on k3s v1.34.12, v1.35.9, v1.36.5 and v1.37.1. On the three older versions, the empty block moved the pod into the BestEffort cgroup slice. The kubelet set its &lt;code&gt;oom_score_adj&lt;/code&gt; to 1000, the highest value the kernel accepts. Next to a busy neighbour, a pod that requested half a CPU got 0.06 to 0.08 cores instead of 0.57 to 0.58. A namespace quota that blocks BestEffort pods rejected it. On 1.37.1 the same pods kept their Burstable and Guaranteed classes.&lt;/p&gt;

&lt;p&gt;One thing did not change, and the research we started from got it wrong. It said these pods are first in line for kubelet eviction under memory pressure. They are not. The kubelet's eviction ranking compares memory usage with the memory request. For an empty block, that request still comes from the containers. The QoS class is not part of the ranking, and the kubelet evicted our pods in the same order on every version.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;On k3s v1.34.12, v1.35.9 and v1.36.5, a pod with container requests and limits plus &lt;code&gt;spec.resources: {}&lt;/code&gt; reported &lt;code&gt;qosClass: BestEffort&lt;/code&gt;. The Guaranteed variant did too. On v1.37.1 they reported Burstable and Guaranteed. With the new &lt;code&gt;PodLevelResourcesFixKubeletQOSClass&lt;/code&gt; feature gate turned off, 1.37.1 went back to BestEffort.&lt;/li&gt;
&lt;li&gt;The node settings followed the class. &lt;code&gt;oom_score_adj&lt;/code&gt; went from 935 (Burstable) and -997 (Guaranteed) to 1000, and the pod cgroup moved to &lt;code&gt;kubepods-besteffort.slice&lt;/code&gt; with &lt;code&gt;cpu.weight&lt;/code&gt; 1 and no pod-level &lt;code&gt;memory.max&lt;/code&gt;. The container-level limits stayed in place.&lt;/li&gt;
&lt;li&gt;A neighbour pod kept both vCPUs busy. Next to it, the BestEffort-classified pod got a median of 0.061 to 0.083 cores on the three affected versions. The same pod without the empty block got 0.573 to 0.580 (so 10.6% to 14.3%). On 1.37.1 the two were equal.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;ResourceQuota&lt;/code&gt; with scope &lt;code&gt;BestEffort&lt;/code&gt; and &lt;code&gt;pods: "0"&lt;/code&gt; rejected the pod on 1.34 to 1.36 and admitted it on 1.37.&lt;/li&gt;
&lt;li&gt;The kubelet's memory-pressure eviction order was the same on all four versions, in all 12 repetitions: the true BestEffort pod first, then by memory usage relative to the container request. The empty block did not move a pod forward.&lt;/li&gt;
&lt;li&gt;If you run GitLab Runner on Kubernetes, upgrade to 19.5.0 or later. As of 2 November 2026 the latest Kubernetes patch releases for 1.34, 1.35 and 1.36 still have the bug.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A cluster on Kubernetes 1.34, 1.35 or 1.36 (&lt;code&gt;kubectl version&lt;/code&gt;), or a machine where you can run the lab&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If you run GitLab Runner with the Kubernetes executor: its version (&lt;code&gt;gitlab-runner --version&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;To run the lab: a &lt;a href="https://m.do.co/c/2a9bba940f39" rel="noopener noreferrer"&gt;DigitalOcean&lt;/a&gt; token (referral link); one &lt;code&gt;s-2vcpu-4gb&lt;/code&gt; droplet for about an hour costs a few cents&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the empty block comes from
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pod-level resources&lt;/strong&gt; (&lt;a href="https://github.com/kubernetes/enhancements/issues/2837" rel="noopener noreferrer"&gt;KEP-2837&lt;/a&gt;) let you set CPU and memory for the pod as a whole, not only per container. The feature gate &lt;code&gt;PodLevelResources&lt;/code&gt; went alpha in 1.32 and beta, on by default, in 1.34. &lt;a href="https://devops-daily.com/posts/kubernetes-1-36-user-namespaces-pod-resize" rel="noopener noreferrer"&gt;Our Kubernetes 1.36 post&lt;/a&gt; covers the in-place resize of those values.&lt;/p&gt;

&lt;p&gt;The QoS calculation has a shortcut for them. This is &lt;code&gt;ComputePodQOS&lt;/code&gt; at v1.34.0, v1.35.0 and v1.36.0, and still at the latest patches, v1.34.13, v1.35.10 and v1.36.6:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// pkg/apis/core/v1/helper/qos/qos.go, v1.36.6&lt;/span&gt;
&lt;span class="c"&gt;// When pod-level resources are specified, we use them to determine QoS class.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;utilfeature&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultFeatureGate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Enabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PodLevelResources&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resources&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty block is not nil. It has no requests and no limits, so the function returns BestEffort. The loop over the containers never runs. The issue that reported this, &lt;a href="https://github.com/kubernetes/kubernetes/issues/135082" rel="noopener noreferrer"&gt;#135082&lt;/a&gt; from November 2025, says that almost everywhere else Kubernetes does not differentiate between nil and empty pod-level resources. The helper for that, &lt;code&gt;IsPodLevelResourcesSet&lt;/code&gt;, treats &lt;code&gt;{}&lt;/code&gt; as not set.&lt;/p&gt;

&lt;p&gt;The fix is &lt;a href="https://github.com/kubernetes/kubernetes/pull/137150" rel="noopener noreferrer"&gt;PR #137150&lt;/a&gt;, merged on 15 July 2026 for 1.37. It adds the condition &lt;code&gt;IsPodLevelResourcesSet(pod)&lt;/code&gt; behind a new gate, &lt;code&gt;PodLevelResourcesFixKubeletQOSClass&lt;/code&gt;, beta and on by default from 1.37. It was not backported. The patch releases of 27 October 2026, v1.34.13, v1.35.10 and v1.36.6, still have the old condition. The rest of that release is in &lt;a href="https://dev.to/devopsdaily/kubernetes-137-garhwal-what-shipped-and-what-slipped-1cmi"&gt;our Kubernetes 1.37 review&lt;/a&gt;; &lt;code&gt;PodLevelResources&lt;/code&gt; itself stays beta in 1.37.&lt;/p&gt;

&lt;p&gt;GitLab Runner added pod-level resources to its job pods in 18.10.0 (&lt;a href="https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/5922" rel="noopener noreferrer"&gt;MR !5922&lt;/a&gt;). At every tag we read (18.10.0, 19.3.5 and 19.4.3), the helper that builds the block returns a non-nil object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// executors/kubernetes/kubernetes.go, GitLab Runner v19.4.3&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;podResourcesReference&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResourceRequirements&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResourceRequirements&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;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configurationOverwrites&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Limits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configurationOverwrites&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;podLimits&lt;/span&gt;
        &lt;span class="n"&gt;resources&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;configurationOverwrites&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;podRequests&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;resources&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the runner config sets none of &lt;code&gt;pod_cpu_request&lt;/code&gt;, &lt;code&gt;pod_memory_request&lt;/code&gt;, &lt;code&gt;pod_cpu_limit&lt;/code&gt; and &lt;code&gt;pod_memory_limit&lt;/code&gt;, both maps are empty. The pod then goes to the API server with &lt;code&gt;resources: {}&lt;/code&gt;. The MR that fixed it, &lt;a href="https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/7346" rel="noopener noreferrer"&gt;!7346&lt;/a&gt;, says the same: job pods "were showing &lt;code&gt;qosClass: BestEffort&lt;/code&gt; instead of the expected &lt;code&gt;Burstable&lt;/code&gt;", and after the fix &lt;code&gt;.spec.resources&lt;/code&gt; "should now be empty/absent (was &lt;code&gt;{}&lt;/code&gt;)". It merged on 1 October 2026 and shipped in 19.5.0. The 19.4.3 and 19.3.5 tags of 28 October 2026 still have the old function. The class matters only when the runner config gives the job containers requests or limits. Without them, the pods were BestEffort anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab
&lt;/h2&gt;

&lt;p&gt;The lab runs on one DigitalOcean droplet: &lt;code&gt;s-2vcpu-4gb&lt;/code&gt; in &lt;code&gt;fra1&lt;/code&gt;, Ubuntu 24.04, cgroup v2. A cloud firewall allows SSH from one address and nothing else. The Kubernetes API port is never exposed, and &lt;code&gt;kubectl&lt;/code&gt; runs on the node over SSH. For each version, a script removes k3s, installs the pinned release, and runs five stages. The kubelet gets an explicit hard eviction threshold, &lt;code&gt;memory.available&amp;lt;2Gi&lt;/code&gt;. It also gets a 30 second pressure transition period instead of the default 5 minutes, so repetitions do not wait for the node condition to clear.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;pod spec&lt;/strong&gt; resources: {} + container requests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ComputePodQOS&lt;/strong&gt; sees a block: BestEffort&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IsPodLevelResourcesSet&lt;/strong&gt; sees {}: not set&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;oom_score_adj&lt;/strong&gt; 1000&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cgroup slice&lt;/strong&gt; besteffort, cpu.weight 1&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ResourceQuota scope&lt;/strong&gt; BestEffort&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;eviction ranking&lt;/strong&gt; container requests&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Connections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pod spec -&amp;gt; ComputePodQOS&lt;/li&gt;
&lt;li&gt;pod spec -&amp;gt; IsPodLevelResourcesSet&lt;/li&gt;
&lt;li&gt;ComputePodQOS -&amp;gt; oom_score_adj&lt;/li&gt;
&lt;li&gt;ComputePodQOS -&amp;gt; cgroup slice&lt;/li&gt;
&lt;li&gt;ComputePodQOS -&amp;gt; ResourceQuota scope&lt;/li&gt;
&lt;li&gt;IsPodLevelResourcesSet -&amp;gt; eviction ranking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pods:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pod&lt;/th&gt;
&lt;th&gt;Pod-level block&lt;/th&gt;
&lt;th&gt;Container requests&lt;/th&gt;
&lt;th&gt;Container limits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;be&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ctl-burstable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;100m CPU, 256Mi&lt;/td&gt;
&lt;td&gt;500m CPU, 512Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emp-burstable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100m CPU, 256Mi&lt;/td&gt;
&lt;td&gt;500m CPU, 512Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ctl-guaranteed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;200m CPU, 256Mi&lt;/td&gt;
&lt;td&gt;200m CPU, 256Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emp-guaranteed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200m CPU, 256Mi&lt;/td&gt;
&lt;td&gt;200m CPU, 256Mi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plr-burstable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;requests 100m/256Mi, limits 500m/512Mi&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;plr-burstable&lt;/code&gt; is the control for the feature itself: a real pod-level block works as documented on every version. The empty block is the only problem.&lt;/p&gt;

&lt;p&gt;We wrote down the claim and what would disprove it before the droplet existed, and committed it first (&lt;code&gt;CLAIM.md&lt;/code&gt;). Every recorded file carries a hash of the manifests and node scripts, and the summary script refuses to mix files with different hashes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/k8s-empty-pod-resources" rel="noopener noreferrer"&gt;The-DevOps-Daily/k8s-empty-pod-resources on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the class flips on 1.34 to 1.36
&lt;/h2&gt;

&lt;p&gt;The API server stored the empty block exactly as written. On v1.34.12, v1.35.9 and v1.36.5 the kubelet then reported BestEffort for both &lt;code&gt;emp-*&lt;/code&gt; pods, while their controls kept Burstable and Guaranteed. On v1.37.1 the &lt;code&gt;emp-*&lt;/code&gt; pods got the same class as their controls:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;results/demo-k3s-v1.36.5.txt and results/demo-k3s-v1.37.1.txt&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# k3s v1.36.5+k3s1&lt;/span&gt;
&lt;span class="c"&gt;# ... kubectl apply and wait omitted; the whole session is in the file&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; demo &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NAME:.metadata.name,POD-LEVEL:.spec.resources,QOS:.status.qosClass'&lt;/span&gt;
NAME POD-LEVEL QOS
ctl-burstable &amp;lt;none&amp;gt; Burstable
ctl-guaranteed &amp;lt;none&amp;gt; Guaranteed
emp-burstable map[] BestEffort
emp-guaranteed map[] BestEffort
&lt;span class="c"&gt;# k3s v1.37.1+k3s1, same manifests&lt;/span&gt;
&lt;span class="c"&gt;# ... kubectl apply and wait omitted&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; demo &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'NAME:.metadata.name,POD-LEVEL:.spec.resources,QOS:.status.qosClass'&lt;/span&gt;
NAME POD-LEVEL QOS
ctl-burstable &amp;lt;none&amp;gt; Burstable
ctl-guaranteed &amp;lt;none&amp;gt; Guaranteed
emp-burstable map[] Burstable
emp-guaranteed map[] Guaranteed

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same result on every version, from &lt;code&gt;results/summary.md&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pod&lt;/th&gt;
&lt;th&gt;v1.34.12&lt;/th&gt;
&lt;th&gt;v1.35.9&lt;/th&gt;
&lt;th&gt;v1.36.5&lt;/th&gt;
&lt;th&gt;v1.37.1&lt;/th&gt;
&lt;th&gt;v1.37.1, gate off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ctl-burstable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emp-burstable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ctl-guaranteed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Guaranteed&lt;/td&gt;
&lt;td&gt;Guaranteed&lt;/td&gt;
&lt;td&gt;Guaranteed&lt;/td&gt;
&lt;td&gt;Guaranteed&lt;/td&gt;
&lt;td&gt;Guaranteed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emp-guaranteed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;Guaranteed&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plr-burstable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;td&gt;Burstable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;be&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;td&gt;BestEffort&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;plr-burstable&lt;/code&gt;, with real pod-level requests and limits, was Burstable everywhere. Pod-level resources work. Only the empty block breaks the class.&lt;/p&gt;

&lt;p&gt;To check that the 1.37 gate is the fix and not something else in the release, we reinstalled v1.37.1 with &lt;code&gt;PodLevelResourcesFixKubeletQOSClass=false&lt;/code&gt; on both the API server and the kubelet. Both &lt;code&gt;emp-*&lt;/code&gt; pods went back to BestEffort, with &lt;code&gt;oom_score_adj&lt;/code&gt; 1000. The API server and kubelet metrics reported the gate as &lt;code&gt;BETA&lt;/code&gt;, on by default and off in that run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the kernel sees a BestEffort pod
&lt;/h2&gt;

&lt;p&gt;QoS is not only a label. The kubelet turns it into numbers that the kernel uses. We read them from &lt;code&gt;/proc&lt;/code&gt; and &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; on the node, for the main process of each container:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;oom_score_adj of the main container process&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ctl-burstable, every version&lt;/td&gt;
&lt;td&gt;935&lt;/td&gt;
&lt;td&gt;no pod-level block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emp-burstable, 1.34 to 1.36&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;resources: {}, 1.34 to 1.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emp-burstable, 1.37.1&lt;/td&gt;
&lt;td&gt;935&lt;/td&gt;
&lt;td&gt;resources: {}, 1.37.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ctl-guaranteed, every version&lt;/td&gt;
&lt;td&gt;-997&lt;/td&gt;
&lt;td&gt;no pod-level block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emp-guaranteed, 1.34 to 1.36&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;resources: {}, 1.34 to 1.36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emp-guaranteed, 1.37.1&lt;/td&gt;
&lt;td&gt;-997&lt;/td&gt;
&lt;td&gt;resources: {}, 1.37.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Read from /proc//oom_score_adj on the node. The 1.34.12, 1.35.9 and 1.36.5 values are identical. The kernel adds this value to a process's badness score when it picks one to kill. Source: results/summary.md.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The kernel adds &lt;code&gt;oom_score_adj&lt;/code&gt; to each process's badness score before it picks a process to kill, and it accepts values from -1000 to +1000 (&lt;a href="https://github.com/torvalds/linux/blob/v6.8/Documentation/filesystems/proc.rst" rel="noopener noreferrer"&gt;proc.rst&lt;/a&gt;). A Burstable container gets &lt;code&gt;1000 - 1000 * request / node memory&lt;/code&gt;, which is 935 for a 256Mi request on this node. A Guaranteed container gets -997. The empty block gave both 1000, the same as a pod with no resources at all. &lt;code&gt;oom_score&lt;/code&gt;, the current OOM killer score the kernel shows for the process, went from 1290 (Burstable) and 4 (Guaranteed) to 1334.&lt;/p&gt;

&lt;p&gt;The cgroup changed too. On v1.36.5:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ctl-burstable&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;emp-burstable&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ctl-guaranteed&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;emp-guaranteed&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;QoS slice&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubepods-burstable.slice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubepods-besteffort.slice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubepods.slice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubepods-besteffort.slice&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pod &lt;code&gt;cpu.weight&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pod &lt;code&gt;cpu.max&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.5 CPU&lt;/td&gt;
&lt;td&gt;max (none)&lt;/td&gt;
&lt;td&gt;0.2 CPU&lt;/td&gt;
&lt;td&gt;max (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pod &lt;code&gt;memory.max&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;512 MiB&lt;/td&gt;
&lt;td&gt;max (none)&lt;/td&gt;
&lt;td&gt;256 MiB&lt;/td&gt;
&lt;td&gt;max (none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;container &lt;code&gt;cpu.max&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.5 CPU&lt;/td&gt;
&lt;td&gt;0.5 CPU&lt;/td&gt;
&lt;td&gt;0.2 CPU&lt;/td&gt;
&lt;td&gt;0.2 CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;container &lt;code&gt;memory.max&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;512 MiB&lt;/td&gt;
&lt;td&gt;512 MiB&lt;/td&gt;
&lt;td&gt;256 MiB&lt;/td&gt;
&lt;td&gt;256 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oom_score&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1290&lt;/td&gt;
&lt;td&gt;1334&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1334&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The container cgroup still has its own limits, so &lt;code&gt;emp-burstable&lt;/code&gt; cannot use more than 512 MiB or half a CPU. What it loses is everything the kubelet sets at pod and QoS level. The pod cgroup has no memory limit and no CPU quota of its own. It sits in &lt;code&gt;kubepods-besteffort.slice&lt;/code&gt;, where the kubelet sets &lt;code&gt;cpu.weight&lt;/code&gt; to 1 for the whole slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: half a CPU requested, 0.06 to 0.08 cores received
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cpu.weight&lt;/code&gt; 1 matters only when the CPU is busy. To see how much, we ran &lt;code&gt;cpu-neighbour&lt;/code&gt;, a Burstable pod that requests 1 CPU and runs two busy processes, so both vCPUs are always taken. Next to it, &lt;code&gt;cpu-ctl&lt;/code&gt; and &lt;code&gt;cpu-emp&lt;/code&gt; took turns, three times each, alternating which went first. Both request 500m CPU with a 1 CPU limit and run one busy process; &lt;code&gt;cpu-emp&lt;/code&gt; adds the empty block. Each pod reads its own cgroup's &lt;code&gt;usage_usec&lt;/code&gt; over 20 seconds after a 5 second warm-up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CPU used next to a busy neighbour, median of 3 runs&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v1.34.12 cpu-ctl&lt;/td&gt;
&lt;td&gt;0.579 cores&lt;/td&gt;
&lt;td&gt;cpu-ctl (no block)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.34.12 cpu-emp&lt;/td&gt;
&lt;td&gt;0.083 cores&lt;/td&gt;
&lt;td&gt;cpu-emp (resources: {})&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.35.9 cpu-ctl&lt;/td&gt;
&lt;td&gt;0.58 cores&lt;/td&gt;
&lt;td&gt;cpu-ctl (no block)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.35.9 cpu-emp&lt;/td&gt;
&lt;td&gt;0.07 cores&lt;/td&gt;
&lt;td&gt;cpu-emp (resources: {})&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.36.5 cpu-ctl&lt;/td&gt;
&lt;td&gt;0.573 cores&lt;/td&gt;
&lt;td&gt;cpu-ctl (no block)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.36.5 cpu-emp&lt;/td&gt;
&lt;td&gt;0.061 cores&lt;/td&gt;
&lt;td&gt;cpu-emp (resources: {})&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.37.1 cpu-ctl&lt;/td&gt;
&lt;td&gt;0.576 cores&lt;/td&gt;
&lt;td&gt;cpu-ctl (no block)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v1.37.1 cpu-emp&lt;/td&gt;
&lt;td&gt;0.577 cores&lt;/td&gt;
&lt;td&gt;cpu-emp (resources: {})&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;2 vCPU droplet. Both pods request 500m with a 1 CPU limit and run one busy process; the neighbour requests 1 CPU and runs two. Each pod measured its own cgroup usage_usec over 20 s. Source: results/summary.md.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On the three affected versions, &lt;code&gt;cpu-emp&lt;/code&gt; got a median of 0.083, 0.070 and 0.061 cores, against 0.579, 0.580 and 0.573 for &lt;code&gt;cpu-ctl&lt;/code&gt;. That is 14.3%, 12.1% and 10.6% of the control's CPU, or 7.0 to 9.4 times less. On v1.37.1 the two were the same: 0.576 and 0.577 cores.&lt;/p&gt;

&lt;p&gt;The weights explain the direction. &lt;code&gt;cpu-ctl&lt;/code&gt;'s pod cgroup has &lt;code&gt;cpu.weight&lt;/code&gt; 20 for its 500m request and sits in the burstable slice next to the neighbour. The kubelet sets the burstable slice's weight from the sum of the CPU requests of the Burstable pods in it, which gave 63 here. &lt;code&gt;cpu-emp&lt;/code&gt;'s pod cgroup has &lt;code&gt;cpu.weight&lt;/code&gt; 1 and sits in the besteffort slice. The kubelet always sets that slice to the minimum, &lt;code&gt;cpu.weight&lt;/code&gt; 1. The 500m request of &lt;code&gt;cpu-emp&lt;/code&gt; still sets a weight on its container cgroup, but that only counts against other containers in the same pod. On a busy node like this one, a CI build in the BestEffort slice competes with &lt;code&gt;cpu.weight&lt;/code&gt; 1 at that level and can get far less CPU than Burstable or Guaranteed pods. We did not run a build.&lt;/p&gt;

&lt;p&gt;The runs were short and sequential. Each pod ran three times in a fixed alternating order, and we did not record the neighbour's own usage or other load on the host. On the affected versions, each of the nine &lt;code&gt;cpu-emp&lt;/code&gt; runs got less CPU than every &lt;code&gt;cpu-ctl&lt;/code&gt; run, including the runs where &lt;code&gt;cpu-emp&lt;/code&gt; went first. The exact percentages still belong to this setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: a "no BestEffort" quota rejects the pod
&lt;/h2&gt;

&lt;p&gt;Some clusters forbid BestEffort pods with a quota like this one, so every workload must declare resources:&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;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;ResourceQuota&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;no-besteffort&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;hard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;pods&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt;
  &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;BestEffort'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API server applies the scope at create time, before any kubelet sees the pod, and it calls the same &lt;code&gt;GetPodQOS&lt;/code&gt; function. With no status yet, that function computes the class from the spec. On 1.34 to 1.36 it rejected &lt;code&gt;emp-burstable&lt;/code&gt;, a pod whose container sets requests and limits:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;quota.py on k3s v1.36.5&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ grep -A3 ' quota$' results/k3s-v1.36.5/run.log
2026-11-02T10:33:20Z quota
be admitted=False Error from server (Forbidden): error when creating "/root/k8s-empty-pod-resources/manifests/quota/be.yaml": pods "be" is forbidden: exceeded quota: no-besteffort, requested: pods=1, used: pods=0, limited: pods=0
ctl-burstable admitted=True pod/ctl-burstable created
emp-burstable admitted=False Error from server (Forbidden): error when creating "/root/k8s-empty-pod-resources/manifests/quota/emp-burstable.yaml": pods "emp-burstable" is forbidden: exceeded quota: no-besteffort, requested: pods=1, used: pods=0, limited: pods=0

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On v1.37.1 the API server admitted &lt;code&gt;emp-burstable&lt;/code&gt;, and still rejected &lt;code&gt;be&lt;/code&gt;. We did not run a runner. But a job pod from 18.10.0 to 19.4.x carries the same empty block, so in a namespace with this quota it should fail the same way on 1.34 to 1.36. The fix discussion names the reverse risk: a pod "could theoretically bypass restricted BestEffort quotas by being classified as Guaranteed during admission, while still behaving as BestEffort at the node level". That can happen when the API server has the fix and the kubelet does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5: the eviction order did not change
&lt;/h2&gt;

&lt;p&gt;The five eviction pods each allocate a fixed amount of memory and then sleep. They are sized so that the two possible rules predict different orders:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pod&lt;/th&gt;
&lt;th&gt;Memory request&lt;/th&gt;
&lt;th&gt;Allocates&lt;/th&gt;
&lt;th&gt;Working set before the hog&lt;/th&gt;
&lt;th&gt;Usage against request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;be&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;50 MiB&lt;/td&gt;
&lt;td&gt;54 MiB&lt;/td&gt;
&lt;td&gt;above (request is 0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ctl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;256Mi&lt;/td&gt;
&lt;td&gt;200 MiB&lt;/td&gt;
&lt;td&gt;204 MiB&lt;/td&gt;
&lt;td&gt;about 52 MiB below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gua-ctl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;256Mi (= limit)&lt;/td&gt;
&lt;td&gt;150 MiB&lt;/td&gt;
&lt;td&gt;154 MiB&lt;/td&gt;
&lt;td&gt;about 102 MiB below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;emp&lt;/code&gt; + &lt;code&gt;{}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;256Mi&lt;/td&gt;
&lt;td&gt;100 MiB&lt;/td&gt;
&lt;td&gt;104 MiB&lt;/td&gt;
&lt;td&gt;about 152 MiB below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gua-emp&lt;/code&gt; + &lt;code&gt;{}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;256Mi (= limit)&lt;/td&gt;
&lt;td&gt;50 MiB&lt;/td&gt;
&lt;td&gt;54 MiB&lt;/td&gt;
&lt;td&gt;about 202 MiB below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the kubelet ranked by QoS class, &lt;code&gt;emp&lt;/code&gt; and &lt;code&gt;gua-emp&lt;/code&gt; would go out together with &lt;code&gt;be&lt;/code&gt;, before &lt;code&gt;ctl&lt;/code&gt; and &lt;code&gt;gua-ctl&lt;/code&gt;. If it ranks by container requests, &lt;code&gt;be&lt;/code&gt; goes first because any usage exceeds a request of zero, and the rest follow in order of usage minus request: &lt;code&gt;ctl&lt;/code&gt;, &lt;code&gt;gua-ctl&lt;/code&gt;, &lt;code&gt;emp&lt;/code&gt;, &lt;code&gt;gua-emp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A host process outside every pod, &lt;code&gt;hog.py&lt;/code&gt;, then holds &lt;code&gt;MemAvailable&lt;/code&gt; at 1000 MiB. That is below the 2Gi threshold even after all five pods are gone, so the kubelet keeps evicting until nothing evictable is left. The kubelet cannot evict the hog. The pods differ in size on purpose, to separate the two models, so this is not a matched pair. CoreDNS also ran on the node. The kubelet ranked it last in every ranking it logged. In 5 of the 12 logs the window ran long enough to show the next step: the kubelet logged &lt;code&gt;cannot evict a critical pod&lt;/code&gt; once only CoreDNS was left.&lt;/p&gt;

&lt;p&gt;Every repetition on every version gave the request order. This is the kubelet's own ranking on v1.36.5, while it reported &lt;code&gt;emp&lt;/code&gt; and &lt;code&gt;gua-emp&lt;/code&gt; as BestEffort:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;k3s v1.36.5, repetition 1&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# the class the kubelet reported for each pod&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select(.rep == 1) | .qos'&lt;/span&gt; results/k3s-v1.36.5/40-pressure.jsonl
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"be"&lt;/span&gt;:&lt;span class="s2"&gt;"BestEffort"&lt;/span&gt;,&lt;span class="s2"&gt;"ctl"&lt;/span&gt;:&lt;span class="s2"&gt;"Burstable"&lt;/span&gt;,&lt;span class="s2"&gt;"emp"&lt;/span&gt;:&lt;span class="s2"&gt;"BestEffort"&lt;/span&gt;,&lt;span class="s2"&gt;"gua-ctl"&lt;/span&gt;:&lt;span class="s2"&gt;"Guaranteed"&lt;/span&gt;,&lt;span class="s2"&gt;"gua-emp"&lt;/span&gt;:&lt;span class="s2"&gt;"BestEffort"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;# the kubelet's ranking before each eviction; sed keeps the journald timestamp and the message, and drops the host name, the service PID, the severity letter, the kubelet's own timestamp, the second PID and the source file&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'ranked for eviction|evicted successfully'&lt;/span&gt; results/k3s-v1.36.5/40-kubelet-rep1.log | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/^([^]+) .*eviction_manager.go:[0-9]+\] /\1 /'&lt;/span&gt;
2026-11-02T10:38:17.816980+00:00 &lt;span class="s2"&gt;"Eviction manager: pods ranked for eviction"&lt;/span&gt; &lt;span class="nv"&gt;pods&lt;/span&gt;&lt;span class="o"&gt;=[&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/be"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/ctl"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/gua-ctl"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/emp"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/gua-emp"&lt;/span&gt;,&lt;span class="s2"&gt;"kube-system/coredns-7cfb7bc9c7-svb26"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
2026-11-02T10:38:20.108268+00:00 &lt;span class="s2"&gt;"Eviction manager: pod is evicted successfully"&lt;/span&gt; &lt;span class="nv"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/be"&lt;/span&gt;
2026-11-02T10:38:21.127785+00:00 &lt;span class="s2"&gt;"Eviction manager: pods ranked for eviction"&lt;/span&gt; &lt;span class="nv"&gt;pods&lt;/span&gt;&lt;span class="o"&gt;=[&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/ctl"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/gua-ctl"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/emp"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/gua-emp"&lt;/span&gt;,&lt;span class="s2"&gt;"kube-system/coredns-7cfb7bc9c7-svb26"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
2026-11-02T10:38:23.555889+00:00 &lt;span class="s2"&gt;"Eviction manager: pod is evicted successfully"&lt;/span&gt; &lt;span class="nv"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/ctl"&lt;/span&gt;
2026-11-02T10:38:24.579473+00:00 &lt;span class="s2"&gt;"Eviction manager: pods ranked for eviction"&lt;/span&gt; &lt;span class="nv"&gt;pods&lt;/span&gt;&lt;span class="o"&gt;=[&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/gua-ctl"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/emp"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/gua-emp"&lt;/span&gt;,&lt;span class="s2"&gt;"kube-system/coredns-7cfb7bc9c7-svb26"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
2026-11-02T10:38:27.242735+00:00 &lt;span class="s2"&gt;"Eviction manager: pod is evicted successfully"&lt;/span&gt; &lt;span class="nv"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/gua-ctl"&lt;/span&gt;
2026-11-02T10:38:28.259762+00:00 &lt;span class="s2"&gt;"Eviction manager: pods ranked for eviction"&lt;/span&gt; &lt;span class="nv"&gt;pods&lt;/span&gt;&lt;span class="o"&gt;=[&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/emp"&lt;/span&gt;,&lt;span class="s2"&gt;"evict-check/gua-emp"&lt;/span&gt;,&lt;span class="s2"&gt;"kube-system/coredns-7cfb7bc9c7-svb26"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
2026-11-02T10:38:30.555840+00:00 &lt;span class="s2"&gt;"Eviction manager: pod is evicted successfully"&lt;/span&gt; &lt;span class="nv"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/emp"&lt;/span&gt;
2026-11-02T10:38:31.572048+00:00 &lt;span class="s2"&gt;"Eviction manager: pods ranked for eviction"&lt;/span&gt; &lt;span class="nv"&gt;pods&lt;/span&gt;&lt;span class="o"&gt;=[&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/gua-emp"&lt;/span&gt;,&lt;span class="s2"&gt;"kube-system/coredns-7cfb7bc9c7-svb26"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
2026-11-02T10:38:34.078890+00:00 &lt;span class="s2"&gt;"Eviction manager: pod is evicted successfully"&lt;/span&gt; &lt;span class="nv"&gt;pod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"evict-check/gua-emp"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The source explains it. &lt;code&gt;rankMemoryPressure&lt;/code&gt; sorts by three keys. First, whether usage exceeds the memory request. Then pod priority. Then usage minus the memory request. The first and third keys get the request from &lt;code&gt;GetResourceRequestQuantity&lt;/code&gt;. For an empty block, that helper asks &lt;code&gt;IsPodLevelResourcesSet&lt;/code&gt;, gets false, and uses the container requests. Priority does not read requests, and the QoS class is not an input at all. If the kubelet had used a request of zero for &lt;code&gt;emp&lt;/code&gt;, &lt;code&gt;emp&lt;/code&gt; would have been in the first group with &lt;code&gt;be&lt;/code&gt;. It came fourth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When each pod was evicted, 12 repetitions&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;th&gt;Samples&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;be&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;5.6s&lt;/td&gt;
&lt;td&gt;8.350000000000001s&lt;/td&gt;
&lt;td&gt;11.5s&lt;/td&gt;
&lt;td&gt;11.5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ctl&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;9.1s&lt;/td&gt;
&lt;td&gt;11.95s&lt;/td&gt;
&lt;td&gt;15.3s&lt;/td&gt;
&lt;td&gt;15.3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gua-ctl&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;12.7s&lt;/td&gt;
&lt;td&gt;16s&lt;/td&gt;
&lt;td&gt;20.9s&lt;/td&gt;
&lt;td&gt;20.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emp&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;16.1s&lt;/td&gt;
&lt;td&gt;20.15s&lt;/td&gt;
&lt;td&gt;24.7s&lt;/td&gt;
&lt;td&gt;24.7s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gua-emp&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;19.5s&lt;/td&gt;
&lt;td&gt;23.6s&lt;/td&gt;
&lt;td&gt;29.9s&lt;/td&gt;
&lt;td&gt;29.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Seconds after the host hog started, from the kubelet's log. 3 repetitions each on k3s v1.34.12, v1.35.9, v1.36.5 and v1.37.1. Every repetition evicted the pods in the same order. Source: results/summary.md.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In all 12 repetitions the kubelet started to reclaim 3.0 to 9.2 s after the hog started, and evicted the first pod 2.3 to 3.3 s later. After that it evicted one pod at a time. 41 of the 48 gaps between evictions were 4.0 s or less (median 3.6 s, longest 8.2 s). The last pod was gone 19.5 to 29.9 s after the hog started. It does not wait 10 seconds between evictions: after each one it waits for the pod to be cleaned up and then checks again. Its own &lt;code&gt;available&lt;/code&gt; figure at the first eviction was 931 to 1212 MiB, well under the 2Gi threshold and far from empty. The kernel logged no OOM kill in any repetition, so the order is the kubelet's alone.&lt;/p&gt;

&lt;p&gt;The two mechanisms are easy to mix up. The kubelet's eviction manager checks every 10 seconds by default when it has nothing to evict, and it ignores the class. The kernel OOM killer acts when memory runs out before the kubelet frees any. It adds &lt;code&gt;oom_score_adj&lt;/code&gt; to each process's badness score, and the empty block gives the pod the same 1000 as a pod with no resources at all (Finding 2). We did not trigger a kernel OOM, so we did not watch the kernel make that choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we got wrong before measuring
&lt;/h2&gt;

&lt;p&gt;The research note we started from had the core right and two details wrong.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Right:&lt;/strong&gt; Kubernetes 1.34 to 1.36 compute BestEffort for an empty pod-level block. GitLab Runner 18.10 to 19.4 build their job pods with that block (from the source and the fix MR). On 1.34 to 1.36 the pods get &lt;code&gt;oom_score_adj&lt;/code&gt; 1000. 1.37 fixes the class while its new gate is on, which is the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong:&lt;/strong&gt; it called #137150 the upstream issue. That is the fix PR. The issue is #135082.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong, and it matters:&lt;/strong&gt; it said these pods are first in line for kubelet eviction under memory pressure. Finding 5 shows they are not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing:&lt;/strong&gt; the 1.37 fix is a beta feature gate, not a plain code change, and nothing was backported to the 1.34, 1.35 or 1.36 patch releases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We got one prediction wrong ourselves. &lt;code&gt;CLAIM.md&lt;/code&gt; said the BestEffort-classified pod would get under 10% of the control's CPU. It got 10.6% to 14.3%. Our falsification line was 50%, so the claim stands, but the number we wrote down was too pessimistic, and we report it as a miss.&lt;/p&gt;

&lt;p&gt;We also kept a one-repetition smoke run on v1.36.5 (&lt;code&gt;results/_smoke-k3s-v1.36.5/&lt;/code&gt;). It used the same code stamp and gave the same results, but the summary does not count it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Find the pods.&lt;/strong&gt; This query lists every pod with an empty pod-level block and the class it got:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitLab Runner 18.10.0 to 19.4.x: upgrade to 19.5.0 or later.&lt;/strong&gt; The job pods then go out without a pod-level block, and the class comes from the containers again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If you cannot upgrade the runner yet, give the block real values.&lt;/strong&gt; Set &lt;code&gt;pod_cpu_request&lt;/code&gt; and &lt;code&gt;pod_memory_request&lt;/code&gt; (and the &lt;code&gt;_limit&lt;/code&gt; keys if you want limits) under &lt;code&gt;[runners.kubernetes]&lt;/code&gt;. The QoS class then comes from those values, like &lt;code&gt;plr-burstable&lt;/code&gt; above. This is a behaviour change, not only a workaround: the pod cgroup then takes its limits from the pod-level values too, as it did for &lt;code&gt;plr-burstable&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kubernetes 1.37 fixes it by default.&lt;/strong&gt; The fix sits behind a beta gate, so check that nobody turned &lt;code&gt;PodLevelResourcesFixKubeletQOSClass&lt;/code&gt; off.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check your own templates.&lt;/strong&gt; A Helm chart that renders a pod-level block from a value that defaults to &lt;code&gt;{}&lt;/code&gt; produces the same bug. Render the key only when it has content:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An empty &lt;code&gt;resources: {}&lt;/code&gt; inside a container is harmless. Only the pod-level one under &lt;code&gt;spec&lt;/code&gt; changes the class.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;k3s is one distribution.&lt;/strong&gt; We ran upstream Kubernetes code as k3s packages it. A managed service can turn the &lt;code&gt;PodLevelResources&lt;/code&gt; gate off or carry its own patches. Run the &lt;code&gt;jq&lt;/code&gt; query on your own cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No GitLab instance and no runner ran here.&lt;/strong&gt; The runner part rests on its source at each tag and on the MR description. The &lt;code&gt;emp-*&lt;/code&gt; pods reproduce the field the runner sends, not a full job pod with helper and service containers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We did not trigger a kernel OOM kill.&lt;/strong&gt; The &lt;code&gt;oom_score&lt;/code&gt; values are the numbers the kernel uses to choose a victim, read from &lt;code&gt;/proc&lt;/code&gt;. We did not watch it choose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The eviction timing is ours.&lt;/strong&gt; It depends on the 2Gi threshold, the hog's 1000 MiB target and how fast it allocates. The order is the finding, not the seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CPU gap depends on the neighbours.&lt;/strong&gt; One neighbour with a 1 CPU request on a 2 vCPU node gave the numbers above. More Burstable or Guaranteed load should make the BestEffort slice's share smaller. On an idle node the weights do not matter, because they only divide contended CPU time; we did not measure that case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One node, one admission check.&lt;/strong&gt; We did not test scheduling across nodes, or any admission rule other than the one quota. The gate-off run on 1.37.1 covered only the class, the kernel-facing numbers and the quota, not CPU or eviction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other consumers of the class were not tested.&lt;/strong&gt; From the source, the kubelet also rejects new BestEffort pods while the node has the &lt;code&gt;MemoryPressure&lt;/code&gt; condition. The static CPU manager gives exclusive CPUs only to Guaranteed pods. We did not measure either.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;On Kubernetes 1.34, 1.35 and 1.36, with the &lt;code&gt;PodLevelResources&lt;/code&gt; gate at its default (on), &lt;code&gt;spec.resources: {}&lt;/code&gt; makes a pod BestEffort no matter what its containers request. On our node that meant &lt;code&gt;oom_score_adj&lt;/code&gt; 1000 instead of 935 or -997, and a place in the BestEffort slice with &lt;code&gt;cpu.weight&lt;/code&gt; 1 and no pod-level memory or CPU limit. Next to a busy neighbour the pod got about 7 to 9 times less CPU, and a quota that forbids BestEffort pods rejected it. The empty block did not change the kubelet's eviction order. The ranking uses memory requests, and for an empty block those still come from the containers: 12 of 12 repetitions evicted our pods in exactly the order the requests predict. Kubernetes 1.37 fixes the class behind a beta gate that is on by default, and GitLab Runner 19.5.0 stops sending the block. Run the &lt;code&gt;jq&lt;/code&gt; query above on your clusters, and upgrade whichever side you can first.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/kubernetes-empty-pod-resources-besteffort" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>qos</category>
      <category>gitlabrunner</category>
      <category>k3s</category>
    </item>
    <item>
      <title>Can a Vision Model Read Your Grafana Panel? 100 Panels on DigitalOcean Inference</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Wed, 04 Nov 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/can-a-vision-model-read-your-grafana-panel-100-panels-on-digitalocean-inference-5224</link>
      <guid>https://dev.to/devopsdaily/can-a-vision-model-read-your-grafana-panel-100-panels-on-digitalocean-inference-5224</guid>
      <description>&lt;p&gt;Grafana can attach a screenshot of the alerting panel to a notification. A bot can send that image to a vision model and post one line next to the page. NVIDIA Nemotron 3.5 Super VL came to DigitalOcean Serverless Inference on 15 October 2026 (&lt;a href="https://docs.digitalocean.com/release-notes/" rel="noopener noreferrer"&gt;release notes&lt;/a&gt;), so we tested it on that job.&lt;/p&gt;

&lt;p&gt;We drew 100 dashboard panels with known answers and asked four questions about each: the peak value, the time of the peak, whether the data crossed the threshold, and which series is highest. Our claim, written before we measured: Nemotron 3.5 Super VL gets all four right on at least 90 of 100 panels, for under $0.001 per screenshot. It got all four right on &lt;strong&gt;48&lt;/strong&gt; panels with reasoning off and &lt;strong&gt;54&lt;/strong&gt; with reasoning on. The claim failed. The cost limit held only with reasoning off: &lt;strong&gt;$0.00026&lt;/strong&gt; per screenshot, against &lt;strong&gt;$0.0024&lt;/strong&gt; with reasoning on.&lt;/p&gt;

&lt;p&gt;None of the four models we tested came close to 90. Qwen3.8-Max got 65 and Gemma 4 got 64, at &lt;strong&gt;$0.11 per 1,000 screenshots&lt;/strong&gt; for Gemma. Log-scale axes broke every model.&lt;/p&gt;

&lt;p&gt;So the numbers in an alert message should come from the alert payload, not the model. The more useful result is a small bot that lets the model describe the screenshot and lets rules decide whether to trust it. On Nemotron's 300 reads, the rules caught 63 percent of the wrong reads and blocked no correct read. With the wrong screenshot attached on purpose, the bot refused it 99 times in 100.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;All four facts right, of 100 Grafana-style panels: Qwen3.8-Max 65, Gemma 4 64, Nemotron 3.5 Super VL 54 with reasoning and 48 without, Llama 4 Maverick 21. Our bar was 90.&lt;/li&gt;
&lt;li&gt;Peak time and threshold were easy (86 to 99 percent for every model). Peak value and top series were hard. On log axes, the best model read all four right on 8 of 25 panels.&lt;/li&gt;
&lt;li&gt;Reasoning made Nemotron 13 times slower (19 seconds at the median, 82 at worst) and 9 times more expensive. The gain from 48 to 54 is inside the noise.&lt;/li&gt;
&lt;li&gt;At temperature 0, Nemotron gave the same four answers in all three runs on 13 of 100 panels. Qwen3.8-Max did on 76.&lt;/li&gt;
&lt;li&gt;A cross-check against the alert caught 63 percent of Nemotron's wrong reads (81 percent at a 5 percent tolerance). It cannot check the peak time, the series on a stacked panel, or the words in the summary.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A &lt;a href="https://m.do.co/c/2a9bba940f39" rel="noopener noreferrer"&gt;DigitalOcean account&lt;/a&gt; (referral link) with a Serverless Inference model access key&lt;/li&gt;
&lt;li&gt;Node.js 22 or later&lt;/li&gt;
&lt;li&gt;About $1 of inference if you rerun every model; the reasoning-mode run is most of it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/grafana-panel-vision" rel="noopener noreferrer"&gt;The-DevOps-Daily/grafana-panel-vision on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The job and the claim
&lt;/h2&gt;

&lt;p&gt;A bot that reads the attached panel could save the on-call engineer one click: "p99 went to 1.8 s at 13:55 UTC and stayed under the 2.5 s threshold". The question is whether you can trust that sentence during a night page.&lt;/p&gt;

&lt;p&gt;Each model got the same prompt for every panel, with the panel as a PNG, and had to answer in JSON. This is a real answer from Nemotron 3.5 Super VL with reasoning off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"peak"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"peakTime"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"13:45"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thresholdCrossed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"topSeries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Upstream response time stays under 1 second until midday, then spikes sharply to about 1.8 seconds in the early afternoon while remaining below the 2.5 s warning threshold."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The truth for that panel is 1.85 s at 13:55 UTC, not crossed, &lt;code&gt;search&lt;/code&gt;, so this read passes. The tolerances, set before the first run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Peak value&lt;/strong&gt; : within 5 percent of the true peak, after unit conversion. A unit from the wrong family, such as MiB on a latency panel, is wrong. "MB" counts as "MiB", because the axis says MiB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peak time&lt;/strong&gt; : within 5 percent of the panel's time range. That is 3 minutes on a 1-hour panel, 18 on 6 hours and 72 on 24 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threshold crossed&lt;/strong&gt; : exact. The answer is "yes", "no" or "no threshold".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Top series&lt;/strong&gt; : the answer is matched to the nearest legend name, with at most 2 typing errors (1 for short names). A tie, or an answer that names two legend entries, counts as wrong. On a stacked panel, the top series is the one with the thickest band at the peak, not the band drawn on top, and the prompt says so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A panel counts as read only when all four facts are right.&lt;/p&gt;

&lt;h2&gt;
  
  
  100 panels with known answers
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;These are &lt;strong&gt;Grafana-style panels drawn by our own renderer, not screenshots from Grafana&lt;/strong&gt;. Real data has plateaus and near-equal peaks, which our generator rejects so that each question has one answer. Many real dashboards show a legend with Max or Last columns, which would turn the value question into text reading and help the models. A model trained on real Grafana screenshots could read real panels better or worse than ours. We do not know which.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The renderer writes SVG and converts it to PNG with &lt;code&gt;@resvg/resvg-js&lt;/code&gt;. It copies the Grafana dark theme: panel background, Inter font, classic palette, grid, axis labels with units, a list legend, and thresholds as a dashed line, a shaded region or both. There are no tooltips and no legend values. A seeded generator (mulberry32) draws the data, and the truth comes from the same numbers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What varies&lt;/th&gt;
&lt;th&gt;Panels&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unit&lt;/td&gt;
&lt;td&gt;ms 29, percent 22, req/s 23, bytes 26&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Series per panel&lt;/td&gt;
&lt;td&gt;1: 17, 2: 25, 3: 22, 4: 19, 5: 17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Log y-axis&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stacked areas&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Similar colours (shades of one hue)&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small panel (420x230 or 360x200)&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Threshold shown (line, region, both)&lt;/td&gt;
&lt;td&gt;65 (28, 17, 20), crossed on 44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak at the right edge&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time range 1 h, 6 h, 24 h (UTC clock)&lt;/td&gt;
&lt;td&gt;33, 36, 31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noise low, medium, high&lt;/td&gt;
&lt;td&gt;32, 42, 26&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In an earlier project, a bug in our own test data made strong models look wrong, so we checked the truth first. A second script reads every panel back from the SVG alone, the way a reader would: the peak from the tick labels, the time from the clock labels, and the top series from the line colours and the legend (on the 19 stacked panels, from the band heights at the peak). It agreed with the truth on all 100 panels, with a largest peak error of 0.011 percent. To prove the check can fail, a third script breaks the renderer in a copy: wrong byte labels gave 38 problems, lines drawn 3 percent too high gave 204, and stacked bands drawn in the wrong colours failed the band check on all 19 stacked panels. We also viewed 14 panels next to their truth records.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which models we tested
&lt;/h2&gt;

&lt;p&gt;A probe sent the same question to 15 models, with no image and then with the smallest and the largest panel. A model passed if it answered and its prompt token count rose with the image, which proves the image arrived. 11 passed. Mistral 3 14B returned "model does not support image input", and Kimi K2.6 rejected temperature 0. Our account gets HTTP 403, "not available for your subscription tier", for the GPT models, so this test has open-weight models only.&lt;/p&gt;

&lt;p&gt;Several vision models reason before they answer, which is too slow for a page. On the two probe panels, Kimi K3 took 28 and 165 seconds, DeepSeek V4.1 Flash 45 and 13, and Qwen 3.5 397B 71 (with no answer) and 42. We kept &lt;strong&gt;Nemotron 3.5 Super VL&lt;/strong&gt; (&lt;code&gt;nemotron-3.5-super-vl&lt;/code&gt;), with reasoning off and on, and three models that answered in 1.5 to 3 seconds: &lt;strong&gt;Qwen3.8-Max&lt;/strong&gt; (&lt;code&gt;qwen3.8-max&lt;/code&gt;), &lt;strong&gt;Gemma 4&lt;/strong&gt; (&lt;code&gt;gemma-4-31B-it&lt;/code&gt;) and &lt;strong&gt;Llama 4 Maverick&lt;/strong&gt; (&lt;code&gt;llama-4-maverick&lt;/code&gt;). Nemotron Nano 12B v2 VL was also fast; we left it out to keep the test to four models.&lt;/p&gt;

&lt;p&gt;Every call in the comparison used temperature 0 and at most 1,024 output tokens. Reasoning mode got 16,384, because the reasoning counts against that limit. Without reasoning, each model read every panel three times. Reasoning calls took about 20 seconds each, so that mode read all 100 panels once and a fixed 34-panel subset two more times.&lt;/p&gt;

&lt;p&gt;Nemotron reasons by default. Two request settings turn it off. The &lt;code&gt;/no_think&lt;/code&gt; system prompt does not:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;stage 0b: turning reasoning off on one 1000x500 panel&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/00b-reasoning-switch.mjs nemotron-3.5-super-vl
nemotron-3.5-super-vl | default | HTTP 200 | 22795 ms | completion 4096 | reasoning chars 13191 | $0.00291
nemotron-3.5-super-vl | reasoning_effort=low | HTTP 200 | 22271 ms | completion 4096 | reasoning chars 14191 | $0.00291
nemotron-3.5-super-vl | reasoning_effort=none | HTTP 200 | 1207 ms | completion 99 | reasoning chars 0 | $0.00031
nemotron-3.5-super-vl | chat_template_kwargs.enable_thinking=false | HTTP 200 | 1268 ms | completion 92 | reasoning chars 0 | $0.00031
nemotron-3.5-super-vl | system prompt /no_think | HTTP 200 | 21921 ms | completion 4096 | reasoning chars 14294 | $0.00291

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That probe had a 4,096-token limit, and the reasoning runs used all of it without an answer. In the main run, none of the 168 reasoning calls hit the 16,384 limit. The longest used 11,979 tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;All four right (95% CI)&lt;/th&gt;
&lt;th&gt;Peak value&lt;/th&gt;
&lt;th&gt;Peak time&lt;/th&gt;
&lt;th&gt;Threshold&lt;/th&gt;
&lt;th&gt;Top series&lt;/th&gt;
&lt;th&gt;$ per 1,000&lt;/th&gt;
&lt;th&gt;Latency p50 / p95&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;td&gt;65 (55 to 74)&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;1.47&lt;/td&gt;
&lt;td&gt;1.5 s / 2.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;td&gt;64 (54 to 73)&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;0.11&lt;/td&gt;
&lt;td&gt;2.7 s / 3.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nemotron 3.5 Super VL, reasoning on&lt;/td&gt;
&lt;td&gt;54 (44 to 63)&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;2.37&lt;/td&gt;
&lt;td&gt;19.0 s / 58.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nemotron 3.5 Super VL, reasoning off&lt;/td&gt;
&lt;td&gt;48 (39 to 58)&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;0.26&lt;/td&gt;
&lt;td&gt;1.4 s / 1.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;td&gt;21 (14 to 30)&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;89&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;0.28&lt;/td&gt;
&lt;td&gt;2.7 s / 9.1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Accuracy and cost are from run 1. Latency covers every successful call in all runs, and it is the time of the attempt that succeeded. The parallel runs hit HTTP 429 often (retried attempts: 88 for Nemotron with reasoning off, 7 with reasoning on, 99 for Qwen3.8-Max, 75 for Gemma 4), and those waits are not in the latency figures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Panels with all four facts right, by panel feature (run 1)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Series&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 series&lt;/td&gt;
&lt;td&gt;88.2%&lt;/td&gt;
&lt;td&gt;Nemotron, reasoning off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 series&lt;/td&gt;
&lt;td&gt;88.2%&lt;/td&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 series&lt;/td&gt;
&lt;td&gt;76.5%&lt;/td&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stacked&lt;/td&gt;
&lt;td&gt;31.6%&lt;/td&gt;
&lt;td&gt;Nemotron, reasoning off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stacked&lt;/td&gt;
&lt;td&gt;63.2%&lt;/td&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stacked&lt;/td&gt;
&lt;td&gt;68.4%&lt;/td&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;small panel&lt;/td&gt;
&lt;td&gt;42.1%&lt;/td&gt;
&lt;td&gt;Nemotron, reasoning off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;small panel&lt;/td&gt;
&lt;td&gt;63.2%&lt;/td&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;small panel&lt;/td&gt;
&lt;td&gt;57.9%&lt;/td&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;similar colours&lt;/td&gt;
&lt;td&gt;25.9%&lt;/td&gt;
&lt;td&gt;Nemotron, reasoning off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;similar colours&lt;/td&gt;
&lt;td&gt;55.6%&lt;/td&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;similar colours&lt;/td&gt;
&lt;td&gt;48.1%&lt;/td&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-5 series&lt;/td&gt;
&lt;td&gt;30.6%&lt;/td&gt;
&lt;td&gt;Nemotron, reasoning off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-5 series&lt;/td&gt;
&lt;td&gt;58.3%&lt;/td&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4-5 series&lt;/td&gt;
&lt;td&gt;55.6%&lt;/td&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;log y-axis&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;Nemotron, reasoning off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;log y-axis&lt;/td&gt;
&lt;td&gt;8%&lt;/td&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;log y-axis&lt;/td&gt;
&lt;td&gt;32%&lt;/td&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;100 Grafana-style panels drawn by our renderer, 31 October 2026. Panels per row: 1 series 17, stacked 19, small 19, similar colours 27, 4-5 series 36, log y-axis 25. Rows this small give wide intervals; read them as directions, not rates.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What stands out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Time and threshold are easy.&lt;/strong&gt; Every model got the peak time right on 89 to 99 panels and the threshold on 86 to 97.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A simple panel is almost solved.&lt;/strong&gt; With one series, Nemotron and Qwen3.8-Max both got 15 of 17 right. Every added feature cost accuracy. Similar colours cost Nemotron the most: 16 of its 25 wrong series names were on the 27 similar-colour panels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qwen3.8-Max and Gemma 4 cannot be told apart.&lt;/strong&gt; Each got 14 or 13 panels right that the other got wrong. Both beat Nemotron with reasoning off: Qwen3.8-Max on 20 panels against 3, Gemma 4 on 24 against 8 (sign test p = 0.007).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 5 percent bar hurt Gemma 4 most.&lt;/strong&gt; It rounds: 20,000 req/s for a peak of 21,330, 300 ms for 331. Within 10 percent, Gemma read the value right on 93 panels, Qwen3.8-Max on 87 and Nemotron on 82.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repeat runs at temperature 0 showed another difference. Qwen3.8-Max and Gemma 4 gave the same verdict in all three runs on 97 and 98 panels. Nemotron with reasoning off did on 71, and gave the same four answers on only 13. Llama 4 Maverick gave the same four answers on 92 panels and was right in all three runs on only 21. Consistent is not the same as correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the reads go wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Log axes.&lt;/strong&gt; The models read a log axis as if it were linear, or round to a tick. Nemotron read the value within 5 percent on 1 of 25 log panels, Qwen3.8-Max on 4 and Gemma 4 on 10.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5g4nhwc5b4q216fyghu2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5g4nhwc5b4q216fyghu2.png" alt="A Grafana-style log-scale panel where POST /login spikes to 21.3K req/s between the 10K and 100K ticks" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The spike peaks at 21.3K req/s at 14:28 UTC. Every model found the series and the time. Qwen3.8-Max and Gemma 4 said 20,000 every time, 6.2 percent low. Nemotron's three runs said 30 req/s, 45,000 req/s and 30K req/s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The threshold label as the answer.&lt;/strong&gt; When a spike crosses a dashed line, the label of that line is the easiest number on the panel to read.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5frx18010ztlf3env5ys.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5frx18010ztlf3env5ys.png" alt="A log-scale latency panel with two orange series and a dashed threshold at 200 ms; the inventory series rises at the right edge to about 246 ms" width="600" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rise at the right edge goes to 246 ms, above the 200 ms line. Qwen3.8-Max, Gemma 4 and Llama 4 Maverick answered 200 ms in all three runs: the threshold, not the peak. Nemotron answered 500 ms, 0.3 s and 300 ms. Only Llama 4 Maverick named &lt;code&gt;inventory&lt;/code&gt; in every run, and it said the threshold was not crossed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The band on top is not the biggest.&lt;/strong&gt; On a stacked panel, the series drawn last sits on top, even when its own values are small.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg8na7rgeo2tloxhkviej.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg8na7rgeo2tloxhkviej.png" alt="A stacked error-ratio panel; node-a1 is the thick green band at the bottom and node-c3 is the thin band drawn on top" width="600" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;node-c3&lt;/code&gt; is the thin band on top. Qwen3.8-Max and Llama 4 Maverick named it in all three runs and Nemotron in two. Gemma 4 named &lt;code&gt;node-a1&lt;/code&gt; every time. This panel also shows the hardest threshold style, a shaded region with no line. The models got a region-only threshold right on 12 to 15 of 17 panels, and a region with a line on 19 or 20 of 20.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reasoning on: slower and more expensive, and not clearly better
&lt;/h2&gt;

&lt;p&gt;With reasoning on, Nemotron got 54 panels right against 48. Only reasoning got 14 panels right, and only no-reasoning got 8. A sign test gives p = 0.29, so 100 panels cannot say whether reasoning helps. The cost is clear: 3,349 output tokens per call on average in run 1 (8,163 at the 95th percentile), and 19.0 seconds at the median over all 168 calls, 81.8 at worst.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The usage block hides the reasoning.&lt;/strong&gt; Every reasoning call returned &lt;code&gt;reasoning_tokens: 0&lt;/code&gt;, while &lt;code&gt;completion_tokens&lt;/code&gt; included the reasoning and the reasoning text came back in the reply. If you cap or alert on &lt;code&gt;reasoning_tokens&lt;/code&gt;, you see nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temperature 0 does not explain it.&lt;/strong&gt; Reasoning models are often tuned for a higher temperature, so we ran the 34-panel subset again at 0.6. It got 19 right, against 20 at temperature 0 and 20 with reasoning off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not more repeatable.&lt;/strong&gt; On the subset, reasoning gave the same four answers in all three runs on 3 panels, against 7 with reasoning off.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What a screenshot costs
&lt;/h2&gt;

&lt;p&gt;DigitalOcean's &lt;a href="https://docs.digitalocean.com/products/inference/details/pricing/" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; ("Last verified 30 Oct 2026", read on 31 October) lists no separate image rate for these models. We priced each call from the token counts the API reported, at list input and output prices. That assumes the image tokens inside &lt;code&gt;prompt_tokens&lt;/code&gt; are billed at the input rate. The closed October invoice agrees with this. For each of the four models, the change in billed input, cached input and output tokens is equal to the totals the API reported for our 1,522 recorded calls. Each rate on the invoice is the list price, and the invoice has no separate charge for images.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Image tokens, 360x200&lt;/th&gt;
&lt;th&gt;Image tokens, 1000x500&lt;/th&gt;
&lt;th&gt;Prompt tokens (cached)&lt;/th&gt;
&lt;th&gt;Output tokens&lt;/th&gt;
&lt;th&gt;$ per screenshot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nemotron 3.5 Super VL, reasoning off&lt;/td&gt;
&lt;td&gt;288&lt;/td&gt;
&lt;td&gt;514&lt;/td&gt;
&lt;td&gt;652 (0)&lt;/td&gt;
&lt;td&gt;97&lt;/td&gt;
&lt;td&gt;0.00026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nemotron 3.5 Super VL, reasoning on&lt;/td&gt;
&lt;td&gt;288&lt;/td&gt;
&lt;td&gt;514&lt;/td&gt;
&lt;td&gt;652 (0)&lt;/td&gt;
&lt;td&gt;3,349&lt;/td&gt;
&lt;td&gt;0.0024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;td&gt;68&lt;/td&gt;
&lt;td&gt;498&lt;/td&gt;
&lt;td&gt;594 (262)&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;0.0015&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;td&gt;266&lt;/td&gt;
&lt;td&gt;255&lt;/td&gt;
&lt;td&gt;604 (325)&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;0.00011&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;td&gt;437&lt;/td&gt;
&lt;td&gt;1,017&lt;/td&gt;
&lt;td&gt;1,096 (271)&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;0.00028&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Image tokens are the prompt tokens with the panel minus those of the same question with no image. Gemma 4 uses about 260 at any size, Qwen3.8-Max scales with the pixels, and Llama 4 Maverick jumps from 437 to 1,017 above 600 pixels wide.&lt;/p&gt;

&lt;p&gt;Our first cost numbers were wrong in our favour. Runs 2 and 3 send the same image again, and Qwen3.8-Max, Gemma 4 and Llama 4 Maverick served most of it from the prompt cache: Llama's cached tokens went from 271 in run 1 to 1,044. A bot sees each screenshot once, so we now price run 1 only. That raised Qwen3.8-Max from $1.11 to $1.47 per 1,000 and Gemma 4 from $0.097 to $0.113. Nemotron reported no cached tokens on any call, even for the prompt text that every call shares.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules decide, the model explains
&lt;/h2&gt;

&lt;p&gt;The alert already knows the numbers. In our test, each alert comes from a rule on the panel's query, reduced with &lt;code&gt;max&lt;/code&gt; over the panel's range, so the payload has the peak value, the series label (unless the rule is on a sum), the threshold and the firing state. If your rule reduces with &lt;code&gt;last&lt;/code&gt; or a shorter window, compare against that value instead. The model reads the image blind, with no numbers from the alert, and three rules compare its read with the payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/bot.mjs (shortened). tol is 10% by default.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;crossCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;could not confirm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Peak value against the alert's reduced value.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readPeak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;peak&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;peak value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;B&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;B&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;tol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// 2. Series, when the alert is for one series. A rule on a sum has none.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;series&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;series&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;matchSeries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;topSeries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;series&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;series&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// 3. Threshold state, when the model saw a threshold on the panel.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normThreshold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thresholdCrossed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;th&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;threshold&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;firing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;confirmed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;could not confirm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;checks&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 message always starts with the alert's own facts. The model's sentence is added only when its read agrees. Here is the bot on three panels, with Nemotron and reasoning off:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the bot on three alerts&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/05b-bot-demo.mjs p034 p090 p003
--- p034 (data/panels/p034.png) -&amp;gt; confirmed, 2033 ms
Container RSS: firing, max 3.19 GiB over the last 6h, threshold 2.51 GiB.
Screenshot (checked against the alert): Container RSS memory usage spikes sharply around 08:15 UTC to over 3 GiB, driven primarily by worker-5c6b-h3t7, then settles back to roughly 1-2 GiB.
--- p090 (data/panels/p090.png) -&amp;gt; confirmed, 1380 ms
Checkout latency p95: firing, max 106.72 ms over the last 24h, threshold 73 ms, series search.
Screenshot (checked against the alert): Checkout latency p95 is stable around 30-40 ms until a spike to about 100 ms near 22:00 UTC, crossing the red 100 ms threshold.
--- p003 (data/panels/p003.png) -&amp;gt; could not confirm, 1311 ms
HTTP requests: firing, max 21.33K req/s over the last 6h, threshold 17K req/s, series POST /login.
Could not confirm the screenshot (the screenshot reads 25K req/s, the alert says 21.33K req/s). Open the dashboard before you trust the image.

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The p090 message shows the limit. The value, series and threshold state agree, so the bot says "confirmed", but the summary says "the red 100 ms threshold" and the threshold is 73 ms. The rules check the structured answer, not every word. That is why the bot posts the alert's numbers itself.&lt;/p&gt;

&lt;p&gt;Over all recorded reads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Wrong reads&lt;/th&gt;
&lt;th&gt;Caught at 10%&lt;/th&gt;
&lt;th&gt;Caught at 5%&lt;/th&gt;
&lt;th&gt;Correct reads blocked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nemotron 3.5 Super VL, reasoning off&lt;/td&gt;
&lt;td&gt;155 of 300&lt;/td&gt;
&lt;td&gt;97 (63%)&lt;/td&gt;
&lt;td&gt;125 (81%)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3.8-Max&lt;/td&gt;
&lt;td&gt;109 of 300&lt;/td&gt;
&lt;td&gt;58 (53%)&lt;/td&gt;
&lt;td&gt;90 (83%)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;td&gt;111 of 300&lt;/td&gt;
&lt;td&gt;54 (49%)&lt;/td&gt;
&lt;td&gt;98 (88%)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 4 Maverick&lt;/td&gt;
&lt;td&gt;236 of 300&lt;/td&gt;
&lt;td&gt;143 (61%)&lt;/td&gt;
&lt;td&gt;214 (91%)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Correct reads blocked: 0" is by construction. Our alert value is the true peak and the bot's checks are looser than the scorer's, so a correct read cannot fail them. A real alert that reduces differently from the panel would add false flags. What the 10 percent check let through for Nemotron:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;24 reads with only a value error between 5 and 10 percent, such as 300 ms for 331 ms&lt;/li&gt;
&lt;li&gt;21 wrong series names, all on stacked panels, where the alert is on the sum and has no series to compare&lt;/li&gt;
&lt;li&gt;6 threshold errors where the model said "no threshold" on a panel that shows one; the bot cannot tell that apart from a rule threshold that is not drawn&lt;/li&gt;
&lt;li&gt;7 wrong peak times, alone or with a small value error; the payload has no peak time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then we broke it on purpose: each read was checked against the alert of a different panel with the same unit, like a wrong image on the notification. The bot said "could not confirm" for 99 of 100 reads from Nemotron and from Qwen3.8-Max, and for 100 of 100 from Gemma 4 and Llama 4 Maverick. The pair that passed had a wrong alert on a sum (no series check) whose value was 8.6 percent from the read.&lt;/p&gt;

&lt;p&gt;We expected the model to copy the alert's numbers if the prompt contained them, which is why the bot reads blind. For Nemotron that was wrong. With the wrong alert in the prompt, its peak matched the image on 81 of 100 panels and the alert on 1, and the cross-check still refused 98. We keep the blind read because it costs nothing, but this test does not show that it is necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 100 panels cannot tell you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The claim failed by a wide margin.&lt;/strong&gt; The 95 percent intervals for Nemotron end at 58 and 63 panels, far below 90.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We cannot rank Qwen3.8-Max against Gemma 4,&lt;/strong&gt; or Nemotron with reasoning against without. Both gaps are too small for 100 panels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature rows are directions, not rates.&lt;/strong&gt; Each covers 17 to 36 panels. Gemma 4's 8 of 25 on log axes has a 95 percent interval of 17 to 52 percent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The panels are ours.&lt;/strong&gt; Real Grafana screenshots are a different input, easier in some ways and harder in others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One prompt.&lt;/strong&gt; We did not try a prompt that warns about log axes. We did not use a JSON response mode, and Qwen3.8-Max wrote invalid JSON twice in 300 calls (&lt;code&gt;"thresholdCrossed": no&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One day of one service.&lt;/strong&gt; The models DigitalOcean served on 31 October 2026, at temperature 0. Prices are list prices times reported tokens. The closed October invoice billed the same token totals at the list prices.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Each stage writes a file that the next one reads. Every recorded call is stamped with a hash of the runner code and the inputs. The scorer refuses unstamped, mixed or stale files, and records filed under the wrong configuration. The runner stops before its own count of spend passes a cap ($3.50 by default).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/The-DevOps-Daily/grafana-panel-vision
&lt;span class="nb"&gt;cd &lt;/span&gt;grafana-panel-vision
npm ci
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env &lt;span class="c"&gt;# add your model access key&lt;/span&gt;

node scripts/01-render-panels.mjs &lt;span class="c"&gt;# 100 PNGs + data/truth.jsonl + data/alerts.jsonl&lt;/span&gt;
node scripts/01b-check-panels.mjs &lt;span class="c"&gt;# read every panel back from the SVG&lt;/span&gt;
&lt;span class="nb"&gt;mv &lt;/span&gt;results/raw results/raw-ours &lt;span class="c"&gt;# keep our run; yours goes into results/raw&lt;/span&gt;
node scripts/02-run-models.mjs &lt;span class="nt"&gt;--configs&lt;/span&gt; nemotron-3.5-super-vl,gemma-4-31B-it &lt;span class="nt"&gt;--runs&lt;/span&gt; 3
node scripts/03-score.mjs &lt;span class="c"&gt;# results/summary.md&lt;/span&gt;
node scripts/05-crosscheck.mjs &lt;span class="c"&gt;# how often the bot catches a wrong read&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To test your own dashboards, replace the renderer with exports of your real panels and write the truth for each by hand. It is slower, and it is the only way to know how these models read your panels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;A vision model on DigitalOcean can describe a Grafana panel in about 1.5 seconds for a fraction of a cent. On our 100 panels, no model read the four key facts reliably enough to be the source of truth in a page. Log axes, stacked areas and similar colours caused most errors, and reasoning did not fix them.&lt;/p&gt;

&lt;p&gt;What we would ship:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Take the numbers from the alert, not from the image.&lt;/strong&gt; The payload already has the value, the threshold and the series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the model narrate, and check it first.&lt;/strong&gt; Compare its value, series and threshold state with the payload, and say "could not confirm" when they disagree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a fast model with reasoning off.&lt;/strong&gt; Gemma 4 and Qwen3.8-Max were the most accurate, and Gemma 4 cost $0.11 per 1,000 screenshots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make attached panels easy to read.&lt;/strong&gt; Use a linear axis, distinct colours, few series, and a threshold drawn as a line and a region.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/vision-model-grafana-panel-digitalocean-inference" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>llm</category>
      <category>grafana</category>
    </item>
    <item>
      <title>Neon Anonymized Branches: Mask the Columns, Then Scan the Rest</title>
      <dc:creator>DevOps Daily</dc:creator>
      <pubDate>Tue, 03 Nov 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/devopsdaily/neon-anonymized-branches-mask-the-columns-then-scan-the-rest-8mn</link>
      <guid>https://dev.to/devopsdaily/neon-anonymized-branches-mask-the-columns-then-scan-the-rest-8mn</guid>
      <description>&lt;p&gt;We created an anonymized branch of a 20,000-user SaaS database on Neon. It had masking rules for the six PII columns of the &lt;code&gt;users&lt;/code&gt; table: email, name, phone, street address, date of birth and signup IP. The masking run took 9 seconds. Afterwards, no row in &lt;code&gt;users&lt;/code&gt; held its original email, name, phone, address or IP (3 of the 20,000 random dates of birth matched the original by chance). Then we read every text and JSON column on the branch. &lt;strong&gt;All 20,000 original email addresses were still there&lt;/strong&gt; , and so was every original name, phone number, street address and IP address.&lt;/p&gt;

&lt;p&gt;Most of them were in places nobody writes a rule for: the email copied onto each order at checkout, the JSON payloads of login and checkout events, the text of support tickets. The other 20,000 copies came from the masking run itself. The masking updates each row of &lt;code&gt;users&lt;/code&gt;, so the audit trigger on that table fired 20,000 times and saved every original row into &lt;code&gt;audit_log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Anonymized branches mask the columns you list, and in our runs they did. This post measures what is left in the rest of a realistic schema, and shows the scanner we run as a CI gate before preview branches are made from the masked branch. Everything ran on a real Neon project through the Neon API, and the scripts are public.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Masking the listed columns works.&lt;/strong&gt; No masked email, name, phone, address or IP kept its original value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The copies are not masked.&lt;/strong&gt; After round 1, 18,776 of the 20,000 original emails (93.9%) were still readable outside the audit table: in orders, event payloads, support tickets and invitations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The masking run fires your triggers.&lt;/strong&gt; &lt;code&gt;audit_log&lt;/code&gt; grew from 1,888 to 21,888 rows during masking, and each new row held one user's full original row. The branch refuses connections until the run ends, so you cannot switch the trigger off first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free text and JSON need a decision.&lt;/strong&gt; We found no rule that edits JSON in place, and &lt;code&gt;regexp_replace&lt;/code&gt; removed emails and phone numbers from ticket bodies but not names. Only replacing free text and JSON passed the gate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick generators with care.&lt;/strong&gt; &lt;code&gt;anon.dummy_free_email()&lt;/code&gt; and &lt;code&gt;anon.dummy_safe_email()&lt;/code&gt; made the run fail on a unique column at 20,000 rows, and random IP addresses and phone numbers can be equal to real ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Previews from the passing template were ready in 2.3 to 2.5 seconds.&lt;/strong&gt; The same rules failed the scan after a migration copied emails into a new table.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A Neon account and an API key (Account settings &amp;gt; API keys). Everything here ran on a Scale plan project. Anonymized branches are a beta feature.&lt;/li&gt;
&lt;li&gt;Node.js 22 or newer.&lt;/li&gt;
&lt;li&gt;Basic Postgres knowledge: triggers, &lt;code&gt;jsonb&lt;/code&gt;, materialized views.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What an anonymized branch does
&lt;/h2&gt;

&lt;p&gt;Neon's &lt;a href="https://neon.com/docs/workflows/data-anonymization" rel="noopener noreferrer"&gt;data anonymization&lt;/a&gt; creates a branch of a Lakebase Postgres database from a parent branch, then masks it with &lt;a href="https://postgresql-anonymizer.readthedocs.io/" rel="noopener noreferrer"&gt;PostgreSQL Anonymizer&lt;/a&gt; (the &lt;code&gt;anon&lt;/code&gt; extension, version 2.5.1 on our branches). The masking is static: the run rewrites the rows on the new branch, and the parent does not change. You send the rules with the create call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://console.neon.tech/api/v2/projects/&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;/branch_anonymized"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NEON_API_KEY&lt;/span&gt;&lt;span class="s2"&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;'{
    "branch_create": { "branch": { "name": "template", "parent_id": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAIN_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'" } },
    "masking_rules": [
      { "database_name": "neondb", "schema_name": "public", "table_name": "users",
        "column_name": "full_name", "masking_function": "anon.dummy_name()" }
    ],
    "start_anonymization": true
  }'&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter later:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The branch refuses connections until the run ends (&lt;code&gt;Access to this endpoint is temporarily disabled&lt;/code&gt;). We saw the same error after each of our failed runs, so in our tests a failed run did not hand out unmasked data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /branches/{id}/anonymized_status&lt;/code&gt; reports &lt;code&gt;anonymizing&lt;/code&gt;, then &lt;code&gt;anonymized&lt;/code&gt; or &lt;code&gt;error&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Each rule names one column. Nothing in the API looks at the columns you did not name.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is by design, and Neon's &lt;a href="https://neon.com/blog/pg-redact-content-aware-pii-redaction-with-jev-enforced-in-postgres" rel="noopener noreferrer"&gt;pg_redact post&lt;/a&gt; makes the same point: column rules cannot reach PII inside free text. We wanted to know how much PII that is in an ordinary schema, where it sits, and how to find it before someone gets a connection string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test database
&lt;/h2&gt;

&lt;p&gt;The schema is a small invoicing SaaS. &lt;code&gt;users&lt;/code&gt; holds the PII on purpose. Everything else holds it by accident, the way real apps do:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;users&lt;/strong&gt; email, full_name, phone, address, signup_ip&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;orders&lt;/strong&gt; contact_email, shipping_*&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;support_tickets.body&lt;/strong&gt; free text&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;events.payload&lt;/strong&gt; jsonb&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;invitations&lt;/strong&gt; invitee email, name, message&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;audit_log&lt;/strong&gt; trigger: old and new row&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;customer_summary&lt;/strong&gt; materialized view&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;users.search&lt;/strong&gt; generated tsvector&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Connections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;users -&amp;gt; orders (copied)&lt;/li&gt;
&lt;li&gt;users -&amp;gt; support_tickets.body (quoted)&lt;/li&gt;
&lt;li&gt;users -&amp;gt; events.payload (logged)&lt;/li&gt;
&lt;li&gt;users -&amp;gt; invitations (named)&lt;/li&gt;
&lt;li&gt;users -&amp;gt; audit_log (every UPDATE)&lt;/li&gt;
&lt;li&gt;users -&amp;gt; customer_summary (refresh)&lt;/li&gt;
&lt;li&gt;users -&amp;gt; users.search (generated)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A seeded generator writes 20,000 users, 40,191 orders, 6,558 tickets, 59,738 events and 2,035 invitations (47.6 MB). Then it changes the email or the address of 1,888 users, so the audit trigger has history with former values. All names are made up, emails use the reserved &lt;code&gt;.example&lt;/code&gt; and &lt;code&gt;.test&lt;/code&gt; domains, and phone numbers use the fictional 555-01xx range. The generator records every PII value it writes into each row, so we know what each column holds, and it mixes in decoys such as invoice numbers, tracking numbers and the app's own support address.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scanner
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/The-DevOps-Daily/neon-anonymized-branches" rel="noopener noreferrer"&gt;The-DevOps-Daily/neon-anonymized-branches on GitHub&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scan.mjs&lt;/code&gt; takes two branches: the anonymized branch and its parent. It reads the PII columns you list in &lt;code&gt;pii-sources.json&lt;/code&gt; on the parent (here five columns of &lt;code&gt;users&lt;/code&gt;), then reads every text, JSON, &lt;code&gt;tsvector&lt;/code&gt;, &lt;code&gt;inet&lt;/code&gt; and &lt;code&gt;xml&lt;/code&gt; column of the user tables and materialized views on the anonymized branch (the &lt;code&gt;anon&lt;/code&gt; extension's own tables are left out). Per column it reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;known&lt;/strong&gt; : rows that still hold a value from the parent, also inside free text and JSON. Phone numbers match on their last 10 digits, and a full name matches as two neighbouring words, so &lt;code&gt;/team/tarek-stratkett&lt;/code&gt; in a URL matches "Tarek Stratkett".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pattern&lt;/strong&gt; : values shaped like an email, phone number, IP address or street address that are not known values, such as former emails and people who are not users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;row counts&lt;/strong&gt; of every table that changed during masking, and a &lt;strong&gt;fingerprint&lt;/strong&gt; of the data on the branch (per table, the row count and a sha256 over the sorted md5 of each row).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It fails when a column holds a known value, when a column that no rule replaces holds PII-shaped values, when a rule changed no row, when the branch has a database it did not read, when the branch holds data it cannot read (such as a &lt;code&gt;bytea&lt;/code&gt; column, large objects or foreign tables), or when the data changed during the scan. Two columns here are computed only from masked columns, a materialized view and a generated search column. They are on a review list: their PII-shaped values go to a person instead of failing the gate, and known values in them still fail.&lt;/p&gt;

&lt;p&gt;A pass means the scan found no known value anywhere, and no PII-shaped value outside replaced and review columns. It does not mean the branch is clean. The scanner does not know phone numbers outside the US format, nicknames or encoded values, and it does not read numeric columns.&lt;/p&gt;

&lt;p&gt;Against the unmasked parent, the rows it found matched the planted ground truth for all 33 combinations of column and kind. That is not an independent test of recall, because we planted the data in formats the scanner knows. It shows that the scanner counts what it should on this data. A second check, &lt;code&gt;08-predict.mjs&lt;/code&gt;, predicts each column from the parent's data and the rules, without Neon. Where the real scan differs from the prediction, the masking run did something on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  The first gate was too clever
&lt;/h3&gt;

&lt;p&gt;The first version of the gate raised four false alarms on two templates. Three were values that the fake data generator made by chance and that belong to other users: 2 masked IP addresses and 1 phone number. The fourth was our bug in the &lt;code&gt;tsvector&lt;/code&gt; name check. We first excused any known value that also appeared in a masked column. A review showed that this fails open: a real leak with the same value would pass too.&lt;/p&gt;

&lt;p&gt;So the gate has no exception for chance matches. Every known value fails it, and a person reads the report. On real data this happens more often than in ours, because &lt;code&gt;anon.dummy_name()&lt;/code&gt; returns real first and last names. A false alarm costs a few minutes. A leak costs much more. The old scans are in &lt;code&gt;results/first-gate/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: the columns everyone lists
&lt;/h2&gt;

&lt;p&gt;The first rule set masks the PII columns of &lt;code&gt;users&lt;/code&gt;, the way the docs and the GitHub Action examples do. For the unique email column we used the Console's Random Unique Email, &lt;code&gt;pg_catalog.concat(anon.dummy_uuidv4(), '@example.com')&lt;/code&gt;, which Neon recommends for unique columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;scan the round 1 template&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/scan.mjs --target template-v1 --known main --evidence --out results/scan-template-v1.json
scanned 26 columns in 7 tables of template-v1 (68.8 s)
  audit_log.old_row rule=none known: email 20938, name 21888, phone 18552, address 20950, ip 21888 pattern: email 950, address 938
  audit_log.new_row rule=none known: email 1888, name 1888, phone 1585, address 1888, ip 1888 pattern: email 20000, phone 17807, address 954, ip 20000
  customer_summary.email rule=review known: - pattern: email 20000, phone 227
  events.payload rule=none known: email 19931, name 12816, ip 11888 pattern: ip 8017
  invitations.invitee_email rule=none known: email 585 pattern: email 1450
  invitations.invitee_name rule=none known: name 585 pattern: -
  invitations.message rule=none known: name 2035 pattern: -
  orders.contact_email rule=none known: email 40191 pattern: -
  orders.shipping_name rule=none known: name 36939 pattern: -
  orders.shipping_address rule=none known: phone 31371, address 36939 pattern: phone 3252, address 3252
  support_tickets.body rule=none known: email 1324, name 1231, phone 1162, address 588 pattern: email 687, phone 622
  users.search rule=review known: - pattern: email 20000, phone 220
  warning: public.audit_log has 21888 rows, the parent has 1888 (+20000)
  review: customer_summary.email: 20000 rows hold PII-shaped values (review: materialized view of users.email)
  review: users.search: 20000 rows hold PII-shaped values (review: generated from users.full_name and users.email)
FAIL: 16 problems

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the rows that still hold a user's original PII:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Rows with a user's original PII&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;audit_log.old_row&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;21,888&lt;/td&gt;
&lt;td&gt;21,888&lt;/td&gt;
&lt;td&gt;every kind; 20,000 rows were written by the masking run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orders.contact_email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;40,191&lt;/td&gt;
&lt;td&gt;40,191&lt;/td&gt;
&lt;td&gt;the email, copied at checkout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orders.shipping_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;40,191&lt;/td&gt;
&lt;td&gt;36,939&lt;/td&gt;
&lt;td&gt;the name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orders.shipping_address&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;40,191&lt;/td&gt;
&lt;td&gt;36,939&lt;/td&gt;
&lt;td&gt;address and phone, inside &lt;code&gt;jsonb&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;events.payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;59,738&lt;/td&gt;
&lt;td&gt;34,689&lt;/td&gt;
&lt;td&gt;emails, names, IPs, inside &lt;code&gt;jsonb&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;support_tickets.body&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6,558&lt;/td&gt;
&lt;td&gt;2,486&lt;/td&gt;
&lt;td&gt;free text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invitations.message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,035&lt;/td&gt;
&lt;td&gt;2,035&lt;/td&gt;
&lt;td&gt;the inviter's name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;audit_log.new_row&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;21,888&lt;/td&gt;
&lt;td&gt;1,888&lt;/td&gt;
&lt;td&gt;history from before the branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;invitations.invitee_email&lt;/code&gt;, &lt;code&gt;invitee_name&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2,035&lt;/td&gt;
&lt;td&gt;585&lt;/td&gt;
&lt;td&gt;invitees who are users&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Counted as distinct values, this is how much is still readable on the branch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Readable anywhere&lt;/th&gt;
&lt;th&gt;Readable outside &lt;code&gt;audit_log&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;20,000 of 20,000&lt;/td&gt;
&lt;td&gt;18,776 (93.9%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full name&lt;/td&gt;
&lt;td&gt;17,396 of 17,396&lt;/td&gt;
&lt;td&gt;16,024 (92.1%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;16,967 of 16,967&lt;/td&gt;
&lt;td&gt;13,639 (80.4%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Street address&lt;/td&gt;
&lt;td&gt;20,000 of 20,000&lt;/td&gt;
&lt;td&gt;15,907 (79.5%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP address&lt;/td&gt;
&lt;td&gt;20,000 of 20,000&lt;/td&gt;
&lt;td&gt;9,208 (46.0%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The scan also flagged PII of people who are not users, such as 1,450 invitee emails and 8,017 login IPs that are not signup IPs. The prediction matched 21 of the 26 columns, and each difference points to something the masking run did. Two of them are good news: the generated &lt;code&gt;search&lt;/code&gt; column and the materialized view &lt;code&gt;customer_summary&lt;/code&gt; held no original value, because Postgres computes a generated column again on every &lt;code&gt;UPDATE&lt;/code&gt; and Neon refreshes materialized views after the run. The audit trigger caused the others.&lt;/p&gt;

&lt;h3&gt;
  
  
  The masking run fires your triggers
&lt;/h3&gt;

&lt;p&gt;The prediction for &lt;code&gt;audit_log&lt;/code&gt; was 1,888 rows. The branch had 21,888. Static masking &lt;a href="https://postgresql-anonymizer.readthedocs.io/en/latest/static_masking/" rel="noopener noreferrer"&gt;updates every row&lt;/a&gt; of each table that has a rule, so a row-level &lt;code&gt;AFTER UPDATE&lt;/code&gt; trigger fires once for each masked row. Our trigger is a common audit pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;audit_users&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_jsonb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'UPDATE'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;to_jsonb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the masking wrote 20,000 new audit rows, and each &lt;code&gt;old_row&lt;/code&gt; holds one user's original email, name, phone, address and IP. A rule on &lt;code&gt;users&lt;/code&gt; alone made the branch hold a second full copy of &lt;code&gt;users&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is no clean way to stop this on the branch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The branch refuses connections before the run starts. With &lt;code&gt;start_anonymization: false&lt;/code&gt; our client was refused too, so you cannot run &lt;code&gt;ALTER TABLE ... DISABLE TRIGGER&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;The trigger cannot skip the masking run by role. All 21,888 audit rows on the masked branch record &lt;code&gt;neondb_owner&lt;/code&gt;, the role the app uses too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What worked was a rule on the audit table itself (&lt;code&gt;old_row&lt;/code&gt; and &lt;code&gt;new_row&lt;/code&gt; masked with &lt;code&gt;NULL&lt;/code&gt;). In our runs Neon masked &lt;code&gt;users&lt;/code&gt; first and &lt;code&gt;audit_log&lt;/code&gt; after it, so the masking also cleared the rows that its own trigger had written. We put the audit rules first in one rules file (&lt;code&gt;v2-audit-first&lt;/code&gt;), and the order did not change. We did not find this order documented, so do not depend on it. The 20,000 extra rows stay on the branch with &lt;code&gt;NULL&lt;/code&gt; in both columns, which is why the scanner reports tables whose row count changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: mask the copies, edit the free text
&lt;/h2&gt;

&lt;p&gt;The second rule set (&lt;code&gt;v2-careful&lt;/code&gt;) adds a rule for every structured copy the first scan found: the order contact email and shipping name, the shipping address rebuilt with &lt;code&gt;jsonb_build_object&lt;/code&gt;, the invitee email and name, and the two audit columns. For the ticket bodies it keeps the text and cuts out emails and phone numbers with the same patterns the scanner uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;regexp_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;regexp_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;email pattern&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[email]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="s1"&gt;'&amp;lt;phone pattern&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[phone]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three columns still failed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Rows with a user's original PII&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;events.payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;34,689 (emails 19,931, names 12,816, IPs 11,888)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invitations.message&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;2,035 (names)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;support_tickets.body&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;regexp_replace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,819 (names 1,231, addresses 588)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;regexp_replace&lt;/code&gt; rule did its job. No email or phone number was left in any ticket body, and the JavaScript prediction matched the scan exactly. But regular expressions find shapes, and a name has no shape. "Contact Maeve Ashquist on [phone] before 5pm" is still personal data.&lt;/p&gt;

&lt;p&gt;The events table has no rule because we could not write one that keeps the payload. The rule lab (&lt;code&gt;03-rule-lab.mjs&lt;/code&gt;) tried 17 expressions, each on its own anonymized branch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Expression&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;anon.dummy_ipv4()::inet&lt;/code&gt;, &lt;code&gt;CAST(... AS inet)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;rejected with 400: not a valid label&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CASE WHEN id % 2 = 0 THEN 'even' ELSE 'odd' END&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rejected with 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_catalog.jsonb_set(doc, '{email}', ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;rejected with 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_catalog.regexp_replace(doc, ...)&lt;/code&gt; on &lt;code&gt;jsonb&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the run failed: no &lt;code&gt;regexp_replace(jsonb, ...)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_catalog.jsonb_build_object('email', anon.dummy_safe_email(), ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;masked: the whole document is rebuilt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;masking_value: '{}'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;masked&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule parser accepts function calls from the &lt;code&gt;anon&lt;/code&gt; schema and 36 &lt;code&gt;pg_catalog&lt;/code&gt; functions that the extension labels as trusted, such as &lt;code&gt;concat&lt;/code&gt;, &lt;code&gt;regexp_replace&lt;/code&gt;, &lt;code&gt;md5&lt;/code&gt; and &lt;code&gt;jsonb_build_object&lt;/code&gt;. &lt;code&gt;jsonb_set&lt;/code&gt; is not one of them, and without casts a rule cannot turn &lt;code&gt;jsonb&lt;/code&gt; into text and back. So in our tests a JSON column had two options: rebuild the whole document from fake values, which loses its shape when the shape changes from row to row (our events have four), or replace it with a constant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: replace what you cannot edit
&lt;/h2&gt;

&lt;p&gt;The third rule set (&lt;code&gt;v3-strict&lt;/code&gt;) replaces what round 2 could only edit: ticket bodies become &lt;code&gt;anon.lorem_ipsum(words := 20)&lt;/code&gt;, invitation messages become &lt;code&gt;anon.lorem_ipsum(words := 10)&lt;/code&gt;, and event payloads become &lt;code&gt;{}&lt;/code&gt;. It also takes IP addresses and phone numbers from ranges that no real value can be in (more on that below). That passed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;scan the round 3 template&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/scan.mjs --target template-v3 --known main --evidence --out results/scan-template-v3.json
scanned 26 columns in 7 tables of template-v3 (41.7 s)
  customer_summary.email rule=review known: - pattern: email 20000, phone 212
  users.search rule=review known: - pattern: email 20000, phone 207
  warning: public.audit_log has 21888 rows, the parent has 1888 (+20000)
  review: customer_summary.email: 20000 rows hold PII-shaped values (review: materialized view of users.email)
  review: users.search: 20000 rows hold PII-shaped values (review: generated from users.full_name and users.email)
PASS: no PII from the source columns and no PII-shaped values found outside replaced and review columns (2 to review)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It took 37 seconds to mask six tables. This is how much of the original PII each round left readable, counted as distinct values:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Original PII still readable on the masked branch&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;round 1&lt;/th&gt;
&lt;th&gt;round 2&lt;/th&gt;
&lt;th&gt;round 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;65.2%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full name&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;59.5%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Street address&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;2.9%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP address&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;46%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Distinct original values of the 20,000 users found anywhere on each template, as a share of all distinct values of that kind (20,000 emails, 17,396 full names, 16,967 phones, 20,000 addresses, 20,000 IPs). Round 1: 6 rules on users. Round 2: 14 rules, regexp_replace on ticket bodies. Round 3: 16 rules, free text and JSON replaced.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The pass has a price. The support inbox in a preview shows lorem ipsum, and the events table has no payloads, so code that reads payloads has nothing to test against. If those features need realistic data, you can generate synthetic tickets and events for the template, or keep the free text and give the branch to fewer people. The regex rule is not a middle ground, because it cannot find names.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate, and preview branches from the template
&lt;/h2&gt;

&lt;p&gt;The workflow we tested is: create the anonymized branch, wait for the run, scan it, and only then make preview branches from it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;main&lt;/strong&gt; production data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;anonymized branch&lt;/strong&gt; masking rules from git&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;wait&lt;/strong&gt; state: anonymized&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;scan.mjs&lt;/strong&gt; known values + patterns&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;pass: make preview branches&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;fail: delete the branch&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wait matters if you use Neon's GitHub Action. &lt;a href="https://github.com/neondatabase/create-branch-action" rel="noopener noreferrer"&gt;&lt;code&gt;neondatabase/create-branch-action&lt;/code&gt;&lt;/a&gt; v6 accepts &lt;code&gt;masking_rules&lt;/code&gt; and returns the connection string right away, before the masking run ends. A job that connects next gets &lt;code&gt;Access to this endpoint is temporarily disabled&lt;/code&gt;, and after our failed runs the branch stayed closed, so a job that only retries waits until it times out. &lt;code&gt;scan.mjs --wait&lt;/code&gt; polls &lt;code&gt;anonymized_status&lt;/code&gt; first and fails if the run did not finish. The repository has an example nightly workflow with these steps. We ran the steps through the scripts, not in GitHub Actions.&lt;/p&gt;

&lt;p&gt;Two more things to know about the template branch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do not give it an expiry.&lt;/strong&gt; The API refused to create a child from an anonymized branch with &lt;code&gt;expires_at&lt;/code&gt;: &lt;code&gt;Branches with an expiration date cannot have child branches&lt;/code&gt;. Set &lt;code&gt;expires_at&lt;/code&gt; on the previews instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the data before you make previews.&lt;/strong&gt; A scan report is only good for the data it read. &lt;code&gt;04-previews.mjs&lt;/code&gt; refuses to branch from the template unless its report is a pass, the branch has no database and no data that the scan did not read, and the data still has the scanned fingerprint. It then checks each preview against the same fingerprint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the passing template, three previews were ready in 2.3 to 2.5 seconds each, and each one matched the scanned fingerprint. To see the check refuse, the script makes a copy of the template, scans it, and writes one support ticket with a real user's email into it, the way someone might paste a production row into a shared branch:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;previews behind the gate&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/04-previews.mjs --template template-v3
gate: template-v3 passed its scan and still has the scanned data (0ff0ef425216dfe8)
preview-pr-101: ready in 2.4 s, checked after 6.1 s, same data as scanned: true
preview-pr-102: ready in 2.3 s, checked after 6.0 s, same data as scanned: true
preview-pr-103: ready in 2.5 s, checked after 6.1 s, same data as scanned: true
reset br-restless-paper-b9i5hgnz to the template in 3.9 s; same data as scanned again: true
stale gate: after the scan open; after one INSERT refused: data changed since the scan: public.support_tickets (rows 6558 -&amp;gt; 6559)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So treat the template as read-only once it passes: hand out preview branches, never the template's own connection string, and scan again after any change to it.&lt;/p&gt;

&lt;p&gt;Then we broke it on purpose. &lt;code&gt;07-break.mjs&lt;/code&gt; runs a migration on a branch of main that adds a &lt;code&gt;newsletter_signups&lt;/code&gt; table, a copy of 9,956 users' emails and names. The round 3 rules do not know that table, so we made a new template with them and scanned it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;scan after the migration&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/scan.mjs --target template-drift --known main-drift --out results/scan-template-drift.json
scanned 29 columns in 8 tables of template-drift (39.1 s)
  customer_summary.email rule=review known: - pattern: email 20000, phone 213
  newsletter_signups.email rule=none known: email 9956 pattern: -
  newsletter_signups.full_name rule=none known: name 9956 pattern: -
  users.search rule=review known: - pattern: email 20000, phone 207
  warning: public.audit_log has 21888 rows, the parent has 1888 (+20000)
  review: customer_summary.email: 20000 rows hold PII-shaped values (review: materialized view of users.email)
  review: users.search: 20000 rows hold PII-shaped values (review: generated from users.full_name and users.email)
FAIL: 2 problems

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why the scan reads all the text and JSON columns, and not only the columns in the rules file. Rules describe the schema as it was when someone wrote them. The scan checks the schema as it is now. The new &lt;code&gt;first_name&lt;/code&gt; column was not flagged, because a first name alone is not a full name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What your tests will notice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Unique columns.&lt;/strong&gt; Fake data generators repeat themselves. We generated each email function many times and counted the repeats:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Repeats in 1,000&lt;/th&gt;
&lt;th&gt;Repeats in 20,000&lt;/th&gt;
&lt;th&gt;Repeats in 100,000&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon.dummy_free_email()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;td&gt;4,504&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon.dummy_safe_email()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;td&gt;11,967&lt;/td&gt;
&lt;td&gt;90,980&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random Unique Email (&lt;code&gt;concat(anon.dummy_uuidv4(), '@example.com')&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon.fake_email()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;378&lt;/td&gt;
&lt;td&gt;19,001&lt;/td&gt;
&lt;td&gt;99,001&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the unique &lt;code&gt;users.email&lt;/code&gt; column, masking runs with &lt;code&gt;dummy_free_email()&lt;/code&gt; and &lt;code&gt;dummy_safe_email()&lt;/code&gt; both failed with &lt;code&gt;duplicate key value violates unique constraint "users_email_key"&lt;/code&gt;. In the final rule lab run, &lt;code&gt;dummy_free_email()&lt;/code&gt; also failed on a 200-row table with a unique column, so a small table is no guarantee. The unmasked data stayed unreachable, but a nightly template job fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where fake emails point.&lt;/strong&gt; &lt;code&gt;dummy_free_email()&lt;/code&gt; writes addresses at yahoo.com, hotmail.com and gmail.com, so a fake address can belong to a real person. If a preview app sends password resets or receipts, use a function that writes &lt;code&gt;example.com&lt;/code&gt; addresses, or switch email off in previews.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stability.&lt;/strong&gt; Two templates made from the same parent with the round 1 rules shared no masked email, name, phone, address or IP, so a test that expects "user 42 is called X" fails on the next template. Values derived from the primary key do not move: with &lt;code&gt;pg_catalog.concat('user-', id, '@example.com')&lt;/code&gt; and &lt;code&gt;anon.pseudo_first_name(id)&lt;/code&gt;, both templates matched in 20,000 of 20,000 rows. Only use such rules where the input is not PII itself, because anyone with the function can compute the output again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Foreign keys.&lt;/strong&gt; The docs say foreign key columns cannot be masked directly, and that the Console shows an alert instead. Through the API, a rule on &lt;code&gt;orders.user_id&lt;/code&gt; was accepted and the run completed. Afterwards, all 40,191 orders belonged to a different user than on the parent, so joins between orders and users no longer match the parent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generators that can produce real values.&lt;/strong&gt; With the first gate, 2 masked IP addresses from &lt;code&gt;anon.dummy_ipv4()&lt;/code&gt; and 1 phone number from &lt;code&gt;anon.dummy_phone_number()&lt;/code&gt; were equal to real values of other users. The round 3 rules take IP addresses from the 198.51.100.0/24 documentation range and give phone numbers the area code 000, which no real number has. Names are harder, because &lt;code&gt;anon.dummy_name()&lt;/code&gt; returns real names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;anon.detect()&lt;/code&gt;&lt;/strong&gt; suggests PII columns from their names. On our schema it missed &lt;code&gt;full_name&lt;/code&gt;, &lt;code&gt;street_address&lt;/code&gt;, &lt;code&gt;signup_ip&lt;/code&gt; and every copy. It is a start for a rules file, not a check.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long masking takes
&lt;/h2&gt;

&lt;p&gt;The masking run rewrites every row of every table that has a rule. &lt;code&gt;06-timing.mjs&lt;/code&gt; masked three columns of one table at three sizes, three runs each, with the compute fixed at 1 CU:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Table size&lt;/th&gt;
&lt;th&gt;Masking run (median, min to max)&lt;/th&gt;
&lt;th&gt;API call to usable branch (median)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;2.3 MB&lt;/td&gt;
&lt;td&gt;3 s (3 to 4)&lt;/td&gt;
&lt;td&gt;13.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;22.1 MB&lt;/td&gt;
&lt;td&gt;6 s (5 to 10)&lt;/td&gt;
&lt;td&gt;15.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;226.6 MB&lt;/td&gt;
&lt;td&gt;22 s (17 to 32)&lt;/td&gt;
&lt;td&gt;33.8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The time grew much less than the row count: 100 times the rows took about 7 times as long. It also varied between runs, from 17 to 32 seconds at 1,000,000 rows, and three runs per size cannot show why. Neon reports the run's start and end in whole seconds. The time to a usable branch also includes creating the branch and starting its compute. For our 47.6 MB database it was 20.3 seconds for round 1 and 47.6 seconds for round 3, which rewrote six tables. We did not measure storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we could not conclude
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One schema and data we wrote.&lt;/strong&gt; The scanner found everything we planted, in the formats we chose. Real free text has misspellings, nicknames and names in other scripts, so its recall on real data will be lower, and we cannot say by how much.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Names of people who are not users are invisible.&lt;/strong&gt; In our data that left 5,985 names the scanner could not see by design: gift recipients, colleagues, accountants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The table order is observed, not documented.&lt;/strong&gt; With a different schema Neon may mask the audit table first, and then the trigger's copies would stay unmasked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small sizes and one region.&lt;/strong&gt; One project in &lt;code&gt;aws-eu-central-1&lt;/code&gt;, PostgreSQL 18, tables up to 1,000,000 rows, three runs per size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static masking only.&lt;/strong&gt; A &lt;a href="https://neon.com/blog/branching-environments-anonymized-pii" rel="noopener noreferrer"&gt;Neon blog post&lt;/a&gt; says dynamic masking is planned. We tested what is available now.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Anonymized branches masked every column we named. The leaks were all in our schema: copies made at checkout, free text, JSON payloads, a trigger, a new table from a migration. A rules file cannot list what nobody knows is there, so treat the masked branch the way you treat a build: test it before anyone gets a connection string.&lt;/p&gt;

&lt;p&gt;What to do:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write rules for the obvious columns, then scan the masked branch to find the copies.&lt;/li&gt;
&lt;li&gt;Mask audit and history tables in the same rule set, and check row counts after the run.&lt;/li&gt;
&lt;li&gt;Replace JSON columns that hold PII. We found no rule that edits JSON in place.&lt;/li&gt;
&lt;li&gt;Decide what to do with free text. &lt;code&gt;regexp_replace&lt;/code&gt; removes emails and phone numbers, not names.&lt;/li&gt;
&lt;li&gt;Use Random Unique Email (or a value derived from the primary key) for unique email columns, and keep fake emails on &lt;code&gt;example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Gate on the scan, wait for the masking run before you connect, and make previews only from a template that passed. Do not give the template an expiry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run it against your own project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/The-DevOps-Daily/neon-anonymized-branches
&lt;span class="nb"&gt;cd &lt;/span&gt;neon-anonymized-branches &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env &lt;span class="c"&gt;# add a Neon API key&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run setup &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run seed
node scripts/02-template.mjs &lt;span class="nt"&gt;--rules&lt;/span&gt; rules/v1-obvious.json &lt;span class="nt"&gt;--name&lt;/span&gt; template-v1
npm run scan &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--target&lt;/span&gt; template-v1 &lt;span class="nt"&gt;--known&lt;/span&gt; main

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://devops-daily.com/posts/neon-anonymized-branches-what-masking-misses" rel="noopener noreferrer"&gt;devops-daily.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>neon</category>
      <category>postgres</category>
      <category>dataanonymization</category>
      <category>pii</category>
    </item>
  </channel>
</rss>
