<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>BitPage</title><link>https://bitpage.me/</link><description>A technical engineer's blog: real problems, real fixes, real numbers. Backend, databases, infra, incident post-mortems, security, AI-native workflows.</description><generator>Hugo</generator><language>en-us</language><copyright>BitPage</copyright><lastBuildDate>Sun, 06 Sep 2026 04:15:00 +0000</lastBuildDate><atom:link href="https://bitpage.me/index.xml" rel="self" type="application/rss+xml"/><item><title>Mass segfaults on a ZFS host: not bad RAM, an unsigned underflow in zfs_fillpage()</title><link>https://bitpage.me/incidents/zfs-fillpage-underflow-segfaults-systemd-freeze/</link><pubDate>Sun, 06 Sep 2026 04:15:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/incidents/zfs-fillpage-underflow-segfaults-systemd-freeze/</guid><description>A host that answers on no port while the kernel keeps logging is not failing memory. In OpenZFS before 2.4.4 and 2.3.9, zfs_fillpage() computes io_len = i_size - io_off in unsigned types: race an mmap read against an in-place truncate and the length underflows, so dmu_read() zero-fills physical memory far past the page. Fixed on 21 August 2026 in 2.4.4 and 2.3.9 — but not in 2.2.11, released the same minute.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> unrelated processes segfaulting all at once on a ZFS host, plus &lt;code>systemd[1]: Freezing execution&lt;/code> in the journal, is kernel arithmetic rather than failing memory. In OpenZFS before 2.4.4 and 2.3.9, &lt;code>zfs_fillpage()&lt;/code> computed the read length as &lt;code>io_len = i_size - io_off&lt;/code> on unsigned types. Truncate a file in place at the moment an mmap page is faulted in, &lt;code>i_size&lt;/code> drops below &lt;code>io_off&lt;/code>, the difference underflows to near 2⁶⁴, and &lt;code>dmu_read()&lt;/code> zero-fills physical memory far past the page it was asked for. The machine survives that. What finishes it off is systemd, whose &lt;code>CrashAction&lt;/code> defaults to &lt;code>freeze&lt;/code>.&lt;/p>
&lt;p>From outside it looked like a powered-off box: no SSH, no HTTP, no hypervisor UI. A hard reset from the provider&amp;rsquo;s panel brought it back, which left the question nobody enjoys: what that was, and whether it happens again tomorrow.&lt;/p>
&lt;p>An hour of digging produced two answers instead of one. The ZFS bug is the trigger. The price of the failure was set by a systemd default.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cause:&lt;/strong> unsigned underflow in &lt;code>zfs_fillpage()&lt;/code> when an mmap read races an in-place truncate. The kernel zero-fills physical pages belonging to whatever else was there, so the victims are random processes.&lt;/li>
&lt;li>&lt;strong>Fixed in:&lt;/strong> OpenZFS &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.4.4">2.4.4&lt;/a> and &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.3.9">2.3.9&lt;/a>, both published 21 August 2026. On Proxmox, &lt;code>zfsutils-linux&lt;/code> 2.4.4-pve1.&lt;/li>
&lt;li>&lt;strong>Not fixed in:&lt;/strong> &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.2.11">2.2.11&lt;/a>, published the same minute as the other two. Verified by reading the code in the tag, not the release notes.&lt;/li>
&lt;li>&lt;strong>Why the outage runs for hours:&lt;/strong> &lt;code>CrashAction=freeze&lt;/code> is the systemd default. A crashed PID 1 hangs forever, nothing restarts services, &lt;code>systemctl&lt;/code> blocks, sshd dies later with nobody to bring it back.&lt;/li>
&lt;li>&lt;strong>One-minute test against bad RAM:&lt;/strong> ECC counters in &lt;code>/sys/devices/system/edac/mc/mc0/&lt;/code> at zero and no &lt;code>Machine Check&lt;/code> in the journal means the DIMMs are not your problem.&lt;/li>
&lt;li>&lt;strong>Tell in the dump:&lt;/strong> the length register of &lt;code>memset&lt;/code> is not garbage, it is a tidy negative number, exactly minus another register. That is a size computation gone wrong, not failing hardware.&lt;/li>
&lt;li>&lt;strong>One-line mitigation:&lt;/strong> &lt;code>CrashAction=reboot&lt;/code> in a drop-in turns a repeat from an overnight outage into a 10-second one.&lt;/li>
&lt;/ul>
&lt;h2 id="why-the-machine-looks-powered-off-when-it-is-not">Why the machine looks powered off when it is not&lt;/h2>
&lt;p>It looks identical to a powered-off machine from outside, which is why the first hypothesis is always wrong. No port answers, the provider&amp;rsquo;s panel reports power as normal, and support has nothing to add. The obvious theory is that the box went down, so you start looking for the cause of a shutdown: an ACPI event, a watchdog, a power dip in the rack.&lt;/p>
&lt;p>The journal kills that theory. &lt;code>journalctl --list-boots&lt;/code> shows the previous boot, and its entries run right up to a point a couple of minutes before the new boot began, which is the board&amp;rsquo;s POST. The last line before the reset was an nginx worker dying:&lt;/p>
&lt;pre tabindex="0">&lt;code>nginx[…]: [alert] …: worker process … exited on signal 11
&lt;/code>&lt;/pre>&lt;p>The machine never powered off. It ran the whole time and kept writing to the journal that it was in trouble. Nobody outside could hear it.&lt;/p>
&lt;p>That yields a second, nastier fact. The moment things actually broke sits much earlier than the &amp;ldquo;crash time&amp;rdquo; you get from &lt;code>last&lt;/code>. &lt;code>last -x&lt;/code> will show &lt;code>crash&lt;/code> without saying when it started. The real start is the first segfault in the avalanche, and it can be a whole night before the reset.&lt;/p>
&lt;h2 id="why-it-is-not-bad-ram-even-though-it-looks-exactly-like-bad-ram">Why it is not bad RAM, even though it looks exactly like bad RAM&lt;/h2>
&lt;p>Simultaneous segfaults across unrelated processes is the classic signature of a failing DIMM, and the reflex it triggers is the wrong one. Before you schedule downtime for memtest86+, spend a minute on two counters:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>cat /sys/devices/system/edac/mc/mc0/ce_count &lt;span style="color:#6272a4"># corrected errors&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>cat /sys/devices/system/edac/mc/mc0/ue_count &lt;span style="color:#6272a4"># uncorrected&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>journalctl -b -1 -k | grep -i &lt;span style="color:#f1fa8c">&amp;#34;machine check&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Zeros in all three places on an ECC system mean memory as a subsystem is healthy. A single flipped bit would have been caught and logged. While you are there, confirm ECC is actually active rather than merely printed on the invoice:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>dmidecode -t memory | grep -E &lt;span style="color:#f1fa8c">&amp;#34;Error Correction Type|Total Width|Data Width&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>&lt;code>Total Width&lt;/code> needs to be 8 bits wider than &lt;code>Data Width&lt;/code>, 72 against 64, because those are the physical bits doing the correction. Equal widths mean you have no ECC, whatever the spec sheet claims.&lt;/p>
&lt;p>That minute saves hours of downtime spent diagnosing the wrong subsystem. ECC protects against flipped bits, not against code that walks the ordinary execution path and writes zeros into the wrong place.&lt;/p>
&lt;h2 id="four-dead-ends-all-through-the-same-function">Four dead ends, all through the same function&lt;/h2>
&lt;p>Every hypothesis that came from searching the symptom was wrong, and they cost more time than the answer did. Writing them out because each one closes a direction: if your stack looks like this, you can skip these branches.&lt;/p>
&lt;p>&lt;strong>Dead end 1: a Direct IO race.&lt;/strong> The first sensible search on the call stack landed on a real race in OpenZFS, where a concurrent &lt;code>O_DIRECT&lt;/code> write can set &lt;code>db-&amp;gt;db_data = NULL&lt;/code> and &lt;code>dmu_read_impl()&lt;/code> then faults on the dereference. The function names matched mine exactly. Checking the tunable made it look even better:&lt;/p>
&lt;pre tabindex="0">&lt;code># cat /sys/module/zfs/parameters/zfs_dio_enabled
1
&lt;/code>&lt;/pre>&lt;p>I said out loud that this was the leading theory. The registers disagreed: what I had was not a NULL dereference but a &lt;code>memset&lt;/code> with a sensible destination and an absurd length. Different mechanism. I should have checked that in the first minute, since the registers were on screen the whole time.&lt;/p>
&lt;p>&lt;strong>Dead end 2: SLUB corruption on encrypted ZFS.&lt;/strong> &lt;a href="https://github.com/openzfs/zfs/issues/18427">Issue #18427&lt;/a> describes my symptom nearly word for word — mass userspace segfaults, reproducible across several releases. One command ended it:&lt;/p>
&lt;pre tabindex="0">&lt;code># zfs get -r encryption rpool
NAME PROPERTY VALUE SOURCE
rpool encryption off default
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Dead end 3: an already-fixed regression in &lt;code>dmu_read_impl&lt;/code>.&lt;/strong> &lt;a href="https://github.com/openzfs/zfs/issues/17886">Issue #17886&lt;/a> is an oops in the same function, also with memory clobbered by a bad length, closed by &lt;a href="https://github.com/openzfs/zfs/pull/17915">PR #17915&lt;/a>. It read like &amp;ldquo;known bug, fix exists, go upgrade.&amp;rdquo; Then I read the PR: it restores lost parentheses in the &lt;code>BRT_RANGESIZE_TO_NBLOCKS()&lt;/code> macro, and the author states the blast radius plainly — &amp;ldquo;this could cause small memory corruptions for vdevs bigger than 64TB+&amp;rdquo;. A web-workload pool of a few terabytes is an order of magnitude short of that threshold. The stack in that bug also runs through &lt;code>brt_load()&lt;/code> during pool import rather than through mmap under load. Same crash site, different bug.&lt;/p>
&lt;p>&lt;strong>Dead end 4: &amp;ldquo;the server was off for hours.&amp;rdquo;&lt;/strong> Covered above. It died against a continuous journal from the previous boot.&lt;/p>
&lt;p>The unpleasant summary: all four trails ran through &lt;code>dmu_read_impl&lt;/code>. It is a popular function, &amp;ldquo;memory corruption&amp;rdquo; is far too general a symptom, and searching by symptom in that situation returns an endless feed of other people&amp;rsquo;s misfortunes. The break came from arithmetic, not from searching.&lt;/p>
&lt;h2 id="the-giveaway-the-memset-length-is-exactly-minus-another-register">The giveaway: the memset length is exactly minus another register&lt;/h2>
&lt;p>The whole answer sits in the oops dump, and it only needs counting. Trimmed to what matters:&lt;/p>
&lt;pre tabindex="0">&lt;code>BUG: unable to handle page fault for address: ffff8983e8400000
#PF: supervisor write access in kernel mode
#PF: error_code(0x0003) - permissions violation
Comm: php-fpm
RIP: 0010:memset+0xb/0x20
RAX: 0000000000020000 RSI: 0000000000000000 RDI: ffff8983e8400000
RDX: ffffffffff7a4000 R15: 000000000085c000
Call Trace:
? dmu_read_impl+0x21e/0x230 [zfs]
dmu_read [zfs]
zfs_fillpage [zfs]
zfs_getpage [zfs]
zpl_read_folio [zfs]
filemap_read_folio
filemap_fault
__do_fault
handle_mm_fault
&lt;/code>&lt;/pre>&lt;p>Read the &lt;code>memset&lt;/code> arguments. Under the x86-64 SysV ABI those are &lt;code>RDI&lt;/code> for destination, &lt;code>RSI&lt;/code> for the fill byte, &lt;code>RDX&lt;/code> for the count.&lt;/p>
&lt;p>&lt;code>RSI = 0&lt;/code>, so it is zero-filling. &lt;code>RDX = 0xffffffffff7a4000&lt;/code> looks like garbage at a glance. Now take &lt;code>R15 = 0x85c000 = 8,765,440&lt;/code>. This is the part worth slowing down for: &lt;code>0xffffffffff7a4000&lt;/code> is exactly −8,765,440 in two&amp;rsquo;s complement. Not a random value, not a clobbered register. A tidy negative number.&lt;/p>
&lt;p>A negative length does not appear from nowhere. It means something subtracted a larger number from a smaller one in unsigned arithmetic. The culprit is not whoever corrupted memory, it is whoever computed the size.&lt;/p>
&lt;p>&lt;code>RAX = 0x20000&lt;/code> fits the picture too: 128 KiB, the default ZFS &lt;code>recordsize&lt;/code>. An ordinary record read whose length went backwards.&lt;/p>
&lt;p>With that hypothesis the search stopped being a search by symptom and became a search by function name plus exact version. The right issue was the first result.&lt;/p>
&lt;h2 id="what-is-broken-in-zfs-an-invariant-that-does-not-survive-the-release-build">What is broken in ZFS: an invariant that does not survive the release build&lt;/h2>
&lt;p>Two lines in &lt;code>zfs_fillpage()&lt;/code> do the damage, and the guard in front of them does not exist in release builds. Here is the code &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.3/module/os/linux/zfs/zfs_vnops_os.c">from the 2.4.3 tag&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">8
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">loff_t&lt;/span> i_size &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">i_size_read&lt;/span>(ip);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">u_offset_t&lt;/span> io_off &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">page_offset&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd">size_t&lt;/span> io_len &lt;span style="color:#ff79c6">=&lt;/span> PAGE_SIZE;
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b">ASSERT3U&lt;/span>(io_off, &lt;span style="color:#ff79c6">&amp;lt;&lt;/span>, i_size);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> (io_off &lt;span style="color:#ff79c6">+&lt;/span> io_len &lt;span style="color:#ff79c6">&amp;gt;&lt;/span> i_size)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> io_len &lt;span style="color:#ff79c6">=&lt;/span> i_size &lt;span style="color:#ff79c6">-&lt;/span> io_off;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The invariant is stated in plain sight: the page offset must be smaller than the file size. One line down, a subtraction is built on it. All honest, right up until you open the definition of &lt;code>ASSERT3U&lt;/code> for a non-debug build in &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.3/include/os/linux/spl/sys/debug.h">&lt;code>include/os/linux/spl/sys/debug.h&lt;/code>&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4">/*
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * Debugging disabled (--disable-debug)
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">#ifdef NDEBUG
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">#define ASSERT3U(x, y, z) \
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6"> ((void) sizeof ((uintptr_t)(x)), (void) sizeof ((uintptr_t)(z)))
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Two &lt;code>sizeof&lt;/code> expressions that check nothing. The header says so directly: assertions are &amp;ldquo;compiled out when NDEBUG is defined, this is the default behavior for the SPL.&amp;rdquo; Distributions, Proxmox included, build ZFS that way. So the invariant is documented, and unenforced exactly where enforcement would have mattered.&lt;/p>
&lt;p>Truncating the file in place while a page is being faulted in breaks that invariant. &lt;code>i_size&lt;/code> drops below &lt;code>io_off&lt;/code>, the subtraction underflows, and &lt;code>io_len&lt;/code> becomes a number close to 2⁶⁴. &lt;code>dmu_read()&lt;/code> takes it at face value and fills.&lt;/p>
&lt;p>The fix adds the runtime check the assertion pretended to be. From the &lt;a href="https://github.com/openzfs/zfs/blob/zfs-2.4.4/module/os/linux/zfs/zfs_vnops_os.c">2.4.4 tag&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 7
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 8
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f"> 9
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">10
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">11
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">12
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-c" data-lang="c">&lt;span style="display:flex;">&lt;span> &lt;span style="color:#6272a4">/*
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * The page may be faulted in after the file has been truncated.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> * There is no data to read; just zero-fill the page.
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">if&lt;/span> (io_off &lt;span style="color:#ff79c6">&amp;gt;=&lt;/span> i_size) {
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#8be9fd">void&lt;/span> &lt;span style="color:#ff79c6">*&lt;/span>zva &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#50fa7b">kmap&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">memset&lt;/span>(zva, &lt;span style="color:#bd93f9">0&lt;/span>, PAGE_SIZE);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">kunmap&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">ClearPageError&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#50fa7b">SetPageUptodate&lt;/span>(pp);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> &lt;span style="color:#ff79c6">return&lt;/span> (&lt;span style="color:#bd93f9">0&lt;/span>);
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> }
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The commit is &lt;a href="https://github.com/openzfs/zfs/commit/223b8bc446851e5e796e5446ac24d03bbf468f43">&lt;code>223b8bc446&lt;/code>&lt;/a>, &amp;ldquo;linux: handle mmap read beyond file size&amp;rdquo;, merged 30 June 2026 by Brian Behlendorf. Its trailers are worth reading: &lt;code>Reported-by: Iliya Polihronov (@vnsavage) (Automattic)&lt;/code>, &lt;code>Reviewed-by: Alexander Motin&lt;/code>. WordPress.com hit this independently, which fits — it is a PHP-shaped bug. &lt;code>readfile()&lt;/code> on a cache file that another request is rewriting in place is the exact race.&lt;/p>
&lt;p>The crashing process in my dump is &lt;code>php-fpm&lt;/code>, running under an unprivileged LXC. Its uid maps to 82 inside the container, which is &lt;code>www-data&lt;/code> in Alpine, so it was PHP from an Alpine image in Docker inside LXC, on a Debian host. Four layers down from the kernel, and none of them at fault.&lt;/p>
&lt;h2 id="which-versions-are-actually-fixed-and-which-release-from-the-same-day-is-not">Which versions are actually fixed, and which release from the same day is not&lt;/h2>
&lt;p>Three OpenZFS releases went out on 21 August 2026, and the fix is in two of them. Checking release notes is not enough here, so I read &lt;code>zfs_vnops_os.c&lt;/code> in each tag:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Branch&lt;/th>
&lt;th>Release&lt;/th>
&lt;th>Published&lt;/th>
&lt;th>&lt;code>zfs_fillpage()&lt;/code> guarded&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>2.4&lt;/td>
&lt;td>zfs-2.4.4&lt;/td>
&lt;td>2026-08-21&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2.3&lt;/td>
&lt;td>zfs-2.3.9&lt;/td>
&lt;td>2026-08-21&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2.2&lt;/td>
&lt;td>zfs-2.2.11&lt;/td>
&lt;td>2026-08-21&lt;/td>
&lt;td>&lt;strong>no&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three tags were published within about a minute of each other. Release notes for 2.4.4 and 2.3.9 both carry the line &lt;code>linux: handle mmap read beyond file size #18715&lt;/code>; the notes for 2.2.11 do not. In the 2.2.11 tree, &lt;code>ASSERT3U(io_off, &amp;lt;, i_size);&lt;/code> and the bare subtraction are still there. The same holds in the 2.1 branch, so this is an old bug rather than a fresh regression, and downgrading to an older kernel does not help.&lt;/p>
&lt;p>Check your own build rather than trusting a version number:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>zfs version
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># then, for the tag you are on:&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>curl -sf &lt;span style="color:#f1fa8c">&amp;#34;https://raw.githubusercontent.com/openzfs/zfs/zfs-&lt;/span>&lt;span style="color:#ff79c6">$(&lt;/span>zfs version | head -1 | cut -d- -f2 | cut -d_ -f1&lt;span style="color:#ff79c6">)&lt;/span>&lt;span style="color:#f1fa8c">/module/os/linux/zfs/zfs_vnops_os.c&amp;#34;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> | grep -c &lt;span style="color:#f1fa8c">&amp;#39;io_off &amp;gt;= i_size&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Nonzero means the guard is in. Use &lt;code>curl -sf&lt;/code>: without &lt;code>-f&lt;/code>, a nonexistent tag returns a 404 page that greps to &amp;ldquo;0 matches&amp;rdquo;, which is indistinguishable from &amp;ldquo;the fix is missing.&amp;rdquo; I nearly recorded that as a result once.&lt;/p>
&lt;p>On Proxmox the fixed package is already in the ordinary repository. In &lt;code>pve-no-subscription&lt;/code> for trixie, &lt;code>zfsutils-linux&lt;/code> is at 2.4.4-pve1, so this is a plain &lt;code>apt&lt;/code> upgrade plus a reboot rather than anything exotic.&lt;/p>
&lt;p>Upstream the story is not closed. &lt;a href="https://github.com/openzfs/zfs/issues/18787">Issue #18787&lt;/a>, opened 12 July 2026 by the Incus maintainer, who was losing hosts full of LAMP containers, is still open today even though the fix landed two weeks before it was filed. Track the PR number, not the ticket state. A &lt;a href="https://github.com/openzfs/zfs/pull/18824">regression test&lt;/a> followed on 18 July.&lt;/p>
&lt;h2 id="why-the-outage-lasts-hours-crashaction-defaults-to-freeze">Why the outage lasts hours: CrashAction defaults to freeze&lt;/h2>
&lt;p>The bug corrupts memory in milliseconds; the outage lasts all night because of a systemd default. Milliseconds after the corruption started, PID 1 caught its own crash:&lt;/p>
&lt;pre tabindex="0">&lt;code>systemd[1]: Caught &amp;lt;ABRT&amp;gt;, from our own process.
systemd[1]: Caught &amp;lt;ABRT&amp;gt;, dumped core as pid …
systemd[1]: Freezing execution.
&lt;/code>&lt;/pre>&lt;p>&lt;code>CrashAction=&lt;/code> arrived in systemd 256, alongside the &lt;code>systemd.crash_action=&lt;/code> kernel command line option. The manual is unambiguous about the default:&lt;/p>
&lt;blockquote>
&lt;p>Takes one of &amp;ldquo;freeze&amp;rdquo;, &amp;ldquo;reboot&amp;rdquo; or &amp;ldquo;poweroff&amp;rdquo;. Defaults to &amp;ldquo;freeze&amp;rdquo;. If set to &amp;ldquo;freeze&amp;rdquo;, the system will hang indefinitely when the system manager (PID 1) crashes. If set to &amp;ldquo;reboot&amp;rdquo;, the system manager (PID 1) will reboot the machine automatically when it crashes, after a 10s delay.&lt;/p>
&lt;/blockquote>
&lt;p>Freezing makes sense on a desktop, or anywhere an operator is standing next to the console and wants the corpse preserved. On rented hardware in someone else&amp;rsquo;s data centre it means &amp;ldquo;hang forever and wait for a human to notice.&amp;rdquo;&lt;/p>
&lt;p>With PID 1 frozen the system loses the ability to repair itself. Nothing restarts crashed services, &lt;code>systemctl&lt;/code> blocks, a clean shutdown is impossible. sshd went next, and after its last line there is not one further mention of it in the journal: no login attempts, no refusals. Port 22 simply had nobody listening. nginx meanwhile kept crashing at the same instruction pointer, tens of thousands of times, roughly twice a second, all night. The master was alive and forking workers; each worker inherited poisoned memory and died at once. A restart would have fixed it, and there was no one left to issue one.&lt;/p>
&lt;p>The mitigation is one drop-in file:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-ini" data-lang="ini">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#6272a4"># /etc/systemd/system.conf.d/10-crash-reboot.conf&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">[Manager]&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b">CrashAction&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>&lt;span style="color:#f1fa8c">reboot&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>systemd-analyze cat-config systemd/system.conf &lt;span style="color:#6272a4"># inspect before applying&lt;/span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>systemctl daemon-reexec &lt;span style="color:#6272a4"># apply to the running PID 1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>A repeat now costs ten seconds instead of a night. That is the whole trade: I cannot remove the bug from a release that does not contain the fix, so I bought recovery time instead of prevention.&lt;/p>
&lt;h2 id="side-gotchas-worth-stealing">Side gotchas worth stealing&lt;/h2>
&lt;p>Two smaller findings came out of the same morning, and both are worth checking on any Docker host.&lt;/p>
&lt;p>&lt;strong>A &lt;code>restart: on-failure&lt;/code> policy survives a crash but may not survive a clean reboot.&lt;/strong> The containers came back after the hard reset because their processes were killed by SIGKILL, so the exit code was nonzero and the policy fired. On a graceful reboot dockerd sends SIGTERM, and anything that exits 0 (nginx does) is not a failure, so &lt;code>on-failure&lt;/code> leaves it down. The configuration survives the disaster and can fail the maintenance window. Switching everything to &lt;code>unless-stopped&lt;/code> with &lt;code>docker update&lt;/code> fixes it live, no recreation and no downtime.&lt;/p>
&lt;p>&lt;strong>A container with no &lt;code>restart:&lt;/code> line at all defaults to &lt;code>no&lt;/code>.&lt;/strong> One monitoring container was in that state, so after every reboot it stayed down, and the proxy that depended on it spun through dozens of restarts inside half an hour with &lt;code>host not found in upstream&lt;/code>. &lt;code>depends_on&lt;/code> does not help here: it orders &lt;code>docker compose up&lt;/code> and has no effect when the daemon starts containers on boot.&lt;/p>
&lt;h2 id="what-to-do-right-now">What to do right now&lt;/h2>
&lt;ol>
&lt;li>Check whether your ZFS build carries the guard: &lt;code>zfs version&lt;/code>, then look for &lt;code>io_off &amp;gt;= i_size&lt;/code> in &lt;code>zfs_fillpage()&lt;/code> for that tag. Anything on 2.2.x or 2.1.x is exposed, including 2.2.11 from 21 August 2026.&lt;/li>
&lt;li>Upgrade to 2.4.4 or 2.3.9. On Proxmox trixie that is &lt;code>zfsutils-linux&lt;/code> 2.4.4-pve1 from &lt;code>pve-no-subscription&lt;/code>, then a reboot into the new module.&lt;/li>
&lt;li>If you cannot upgrade yet, cut the recovery time: drop in &lt;code>CrashAction=reboot&lt;/code> and run &lt;code>systemctl daemon-reexec&lt;/code>. Ten seconds against an open-ended hang.&lt;/li>
&lt;li>Add an availability check from outside the host. Mine was found by a person arriving at work, which is a bigger hole than the kernel bug.&lt;/li>
&lt;li>Audit Docker restart policies: &lt;code>docker inspect -f '{{.Name}} {{.HostConfig.RestartPolicy.Name}}' $(docker ps -aq)&lt;/code>. Anything showing &lt;code>no&lt;/code> or &lt;code>on-failure&lt;/code> is a machine that may not come back from a planned reboot.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>Three layers of blame, and you can only fix two of them. The ZFS underflow is upstream&amp;rsquo;s to fix, and as of today it is fixed in 2.4.4 and 2.3.9 while 2.2.11 from the same day still carries it. The systemd default turned a memory-corruption event into an overnight outage, and that costs one line of configuration to change. The missing availability check is what stretched the outage until morning, and that one is nobody&amp;rsquo;s fault but mine.&lt;/p>
&lt;p>The transferable part is the diagnostic move. When the kernel faults inside &lt;code>memset&lt;/code> or &lt;code>memcpy&lt;/code>, look at the length register before you look at anything else, and check whether it is a tidy negative number. If it is, stop investigating the hardware: someone computed a size wrong, and the fastest path to the answer is the function names in the stack plus your exact version, not the symptom.&lt;/p>
&lt;p>Primary sources: &lt;a href="https://github.com/openzfs/zfs/commit/223b8bc446851e5e796e5446ac24d03bbf468f43">commit 223b8bc446&lt;/a> and &lt;a href="https://github.com/openzfs/zfs/pull/18715">PR #18715&lt;/a> in OpenZFS, &lt;a href="https://github.com/openzfs/zfs/issues/18787">issue #18787&lt;/a>, the &lt;a href="https://github.com/openzfs/zfs/releases/tag/zfs-2.4.4">zfs-2.4.4 release&lt;/a>, and &lt;a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.html">systemd(1)&lt;/a> for &lt;code>systemd.crash_action=&lt;/code>.&lt;/p></content:encoded></item><item><title>PostgreSQL conflict with recovery: why both standard fixes make it worse under Patroni</title><link>https://bitpage.me/databases/postgres-conflict-with-recovery-patroni-haproxy/</link><pubDate>Thu, 06 Aug 2026 07:31:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/databases/postgres-conflict-with-recovery-patroni-haproxy/</guid><description>With replication slots enabled, hot_standby_feedback moves the bloat risk onto the primary; raising max_standby_streaming_delay feeds the HAProxy check that ejects the replica. Two hundred cancelled queries and thirty-odd replica ejections looked like one problem, but barely one in twenty lined up.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> Both top search results for &lt;code>canceling statement due to conflict with recovery&lt;/code> are wrong on a Patroni cluster with replication slots and one replica. &lt;code>hot_standby_feedback=on&lt;/code> parks the xmin horizon inside the slot, where it survives the replica&amp;rsquo;s death and bloats the primary; raising &lt;code>max_standby_streaming_delay&lt;/code> converts held-back replay into the exact lag your load balancer ejects the replica for. What I shipped instead was removing &lt;code>on-marked-down shutdown-sessions&lt;/code> from the read backend, after measuring that replica lag runs in single-digit milliseconds at the median against a health-check threshold of 10 MB.&lt;/p>
&lt;p>The cluster: one primary and one replica under Patroni 3.0.2 with etcd, PostgreSQL 15, synchronous replication, &lt;code>use_slots: true&lt;/code>. Web requests from PHP-FPM read through HAProxy 2.4.30 against the replica; CLI work (queues, scheduler) goes to the primary. Once every quarter hour a cron job wipes and rebuilds a large table in full, and it belongs to someone else&amp;rsquo;s business logic, so it is not negotiable. The application was catching &lt;code>SQLSTATE[40001]&lt;/code>, and a developer had already written an interceptor at the &lt;code>PostgresConnection&lt;/code> level that retries the cancelled read on the primary. The question I was handed was whether he&amp;rsquo;d done it right. That took a day to answer and turned out to be about something else entirely.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> drop &lt;code>on-marked-down shutdown-sessions&lt;/code> from the read backend — it was killing dozens of live sessions per ejection, and the application does not see &lt;code>40001&lt;/code> for those, it sees a dead socket.&lt;/li>
&lt;li>&lt;strong>Don&amp;rsquo;t:&lt;/strong> &lt;code>hot_standby_feedback=on&lt;/code> with replication slots and a single replica — the xmin outlives the replica, so the failure mode moves from &amp;ldquo;a report dies&amp;rdquo; to &amp;ldquo;the primary fills its disk.&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>Don&amp;rsquo;t:&lt;/strong> raise &lt;code>max_standby_streaming_delay&lt;/code> when the load balancer health-checks replayed position — you pay for it with fan-out read outages instead of a couple of dozen cancelled queries a day.&lt;/li>
&lt;li>&lt;strong>Measure:&lt;/strong> &lt;code>replay_lag&lt;/code> sampled from the primary once a second for twenty minutes. Median in single-digit milliseconds, p95 in tens, worst case a couple of seconds — against a byte threshold that a ~170 MB burst clears in a few seconds.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> Patroni&amp;rsquo;s &lt;code>lag=&lt;/code> parameter is bytes-only. A requirement stated in seconds cannot be expressed in it at all.&lt;/li>
&lt;/ul>
&lt;h2 id="why-hot_standby_feedback-is-the-wrong-first-move-with-replication-slots">Why hot_standby_feedback is the wrong first move with replication slots&lt;/h2>
&lt;p>Replication slots change what &lt;code>hot_standby_feedback=on&lt;/code> costs you, because the xmin stops being tied to a live connection. The PostgreSQL 15 documentation is upfront about the general risk: the parameter &amp;ldquo;can be used to eliminate query cancels caused by cleanup records, but can cause database bloat on the primary for some workloads&amp;rdquo; (&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html">runtime-config-replication&lt;/a>). That sentence is what every answer on the internet is implicitly waving away when it recommends the setting.&lt;/p>
&lt;p>The part that applies specifically here is one section over, in the description of slots: &amp;ldquo;Replication slots provide an automated way to ensure that the primary does not remove WAL segments until they have been received by all standbys, and that the primary does not remove rows which could cause a recovery conflict even when the standby is disconnected&amp;rdquo; (&lt;a href="https://www.postgresql.org/docs/15/warm-standby.html">warm-standby&lt;/a>).&lt;/p>
&lt;p>Read that as an operator rather than as a feature description. Without slots, feedback xmin lives as long as the session does; kill the replica and the primary is free again. With slots, the xmin settles into &lt;code>pg_replication_slots.xmin&lt;/code> and stays there while the replica is gone. There&amp;rsquo;s one replica in this cluster and no spare. Turning the setting on would mean any replica outage becomes unbounded bloat on the primary, and I&amp;rsquo;d have traded a couple of dozen cancelled read queries a day for a way to take down the whole database. That one didn&amp;rsquo;t need a benchmark to reject.&lt;/p>
&lt;h2 id="why-raising-max_standby_streaming_delay-feeds-the-flap">Why raising max_standby_streaming_delay feeds the flap&lt;/h2>
&lt;p>Delaying WAL replay is only free if nothing downstream measures replay position, and on this cluster the load balancer does exactly that. &lt;code>max_standby_streaming_delay&lt;/code> is the second-most-popular answer, and on paper it is the polite one: instead of killing the query, hold back the apply. The docs describe it as &amp;ldquo;the maximum total time allowed to apply WAL data once it has been received from the primary server,&amp;rdquo; defaulting to 30 seconds (&lt;a href="https://www.postgresql.org/docs/15/runtime-config-replication.html">runtime-config-replication&lt;/a>). The cost stays on the replica, which is what makes it look safe.&lt;/p>
&lt;p>It isn&amp;rsquo;t safe here. HAProxy&amp;rsquo;s health check calls Patroni&amp;rsquo;s &lt;code>/replica?lag=&lt;/code>, and Patroni computes lag from the &lt;em>replayed&lt;/em> position. Held-back replay is, by definition, replay lag. Push the parameter from 30 s to 300 s and every conflict-heavy moment turns into a five-minute-wide window where the balancer can decide the replica is stale and pull it from rotation. The outcome would have been strictly worse than the problem: instead of a couple of dozen cancelled queries per day, fan-out outages across all reads.&lt;/p>
&lt;p>The full set of candidates, and why each one died:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Candidate&lt;/th>
&lt;th>Where the cost lands&lt;/th>
&lt;th>Why it was rejected&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>hot_standby_feedback=on&lt;/code>&lt;/td>
&lt;td>Primary (bloat)&lt;/td>
&lt;td>With slots, xmin survives replica downtime; single replica, no spare&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>max_standby_streaming_delay&lt;/code> 30s → 300s&lt;/td>
&lt;td>Replica (replay lag)&lt;/td>
&lt;td>Replay lag is what the health check ejects on; amplifies flapping&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Health-check threshold 10 MB → 512 MB&lt;/td>
&lt;td>Nothing measurable&lt;/td>
&lt;td>The requirement was stated in seconds; any byte value is a guess at time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HAProxy &lt;code>agent-check&lt;/code> on wall-clock lag&lt;/td>
&lt;td>New moving part&lt;/td>
&lt;td>Correct, but a new component in the infrastructure; deferred until measured&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Drop &lt;code>on-marked-down shutdown-sessions&lt;/code>&lt;/td>
&lt;td>Nothing&lt;/td>
&lt;td>Shipped&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The third row is the one I would have gotten wrong on my own. I proposed raising the byte threshold, and the customer rejected it with a better argument than mine: the requirement was written in time (&amp;ldquo;a second of staleness is fine, half a minute is not&amp;rdquo;), and a byte threshold answers a different question. On steady write load the two are nearly interchangeable. On batch write load they diverge by orders of magnitude, which is the whole story of this cluster.&lt;/p>
&lt;h2 id="two-problems-that-looked-like-one">Two problems that looked like one&lt;/h2>
&lt;p>Conflicts and replica ejections clustered in the same minutes because they share a cause, not because one causes the other; barely one conflict in twenty lines up with an ejection. There were two symptom streams. One in the PostgreSQL log — a couple of hundred cancelled queries over a week and a half:&lt;/p>
&lt;pre tabindex="0">&lt;code>ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
FATAL: terminating connection due to conflict with recovery
&lt;/code>&lt;/pre>&lt;p>And one in the HAProxy log (&lt;code>N&lt;/code> here is the live session count at the moment of the ejection — dozens, in this cluster):&lt;/p>
&lt;pre tabindex="0">&lt;code>Server slave/pg_replica is DOWN, reason: Layer7 wrong status, code: 503,
info: &amp;#34;Service Unavailable&amp;#34;, check duration: 4ms. 0 active and 0 backup
servers left. N sessions active, 0 requeued, 0 remaining in queue.
&lt;/code>&lt;/pre>&lt;p>Both landed in the same handful of minutes each hour. The connection seemed too obvious to check, which is precisely why it deserved checking.&lt;/p>
&lt;p>Before I got there I burned time on two hypotheses that a timestamp comparison would have killed in a minute each:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Checkpoints.&lt;/strong> With &lt;code>checkpoint_timeout=15min&lt;/code> the primary logs a checkpoint every quarter hour. The ejections land on entirely different minutes. No overlap at all.&lt;/li>
&lt;li>&lt;strong>The backup window.&lt;/strong> The backup script raises &lt;code>max_standby_streaming_delay&lt;/code> to 3600 s for the duration of &lt;code>pg_dump&lt;/code>, which looked like an excellent suspect. Every single ejection lasted between 6 and 27 seconds. There is not a single hour-long window in the data.&lt;/li>
&lt;/ul>
&lt;p>Then I lined up the two event streams properly (the logs are in different timezones, so this needs converting to one scale first) and compared: about a third of the replica ejections and barely a twentieth of the conflict events fall in shared windows. Two independent symptoms driven by the same cron job, not a chain.&lt;/p>
&lt;p>I should have run that correlation first. It cost one command, and it invalidated a morning&amp;rsquo;s worth of theories I&amp;rsquo;d been carefully stacking on top of each other.&lt;/p>
&lt;h2 id="what-the-lag-actually-was-in-bytes-and-in-seconds">What the lag actually was, in bytes and in seconds&lt;/h2>
&lt;p>The replica was never slow at applying WAL; it was slow at receiving it, and the difference decides which knob is even relevant. I sampled &lt;code>pg_stat_replication&lt;/code> from the primary once per second:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-sql" data-lang="sql">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">SELECT&lt;/span> now()::time(&lt;span style="color:#bd93f9">0&lt;/span>),
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) &lt;span style="color:#ff79c6">AS&lt;/span> replay_bytes,
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn) &lt;span style="color:#ff79c6">AS&lt;/span> flush_bytes
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">FROM&lt;/span> pg_stat_replication;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Put on a relative time scale, with the figures rounded off, a single burst looks like this:&lt;/p>
&lt;pre tabindex="0">&lt;code>T+0 s replay= 0 MB flush= 0 MB
T+1 s replay=150 MB flush=150 MB ← cron batch starts
T+2 s replay=170 MB flush=170 MB
T+3 s replay= 90 MB flush= 90 MB
T+5 s replay= 0 MB flush= 0 MB ← caught up
&lt;/code>&lt;/pre>&lt;p>&lt;code>flush&lt;/code> tracks &lt;code>replay&lt;/code> to within a fraction of a percent. Nothing is queued waiting to be applied, so the lag is purely transport: the primary generated WAL faster than the link carried it. At rest the replica sits tens of kilobytes behind. The health-check threshold is 10 MB. A burst of ~170 MB clears that threshold by a factor of 17 and is gone within a few seconds.&lt;/p>
&lt;p>Then the measurement that actually settled the argument, using the &lt;code>*_lag&lt;/code> interval columns, sampled once a second over twenty minutes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>&lt;code>replay_lag&lt;/code>&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Median&lt;/td>
&lt;td>single-digit milliseconds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>p95&lt;/td>
&lt;td>tens of milliseconds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Worst sample seen&lt;/td>
&lt;td>a couple of seconds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Samples above 5 s&lt;/td>
&lt;td>none&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Samples above 30 s&lt;/td>
&lt;td>none&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The stated requirement was &amp;ldquo;a second is acceptable, half a minute is not.&amp;rdquo; The replica never came within three orders of magnitude of unacceptable. And the nature of the lag is confirmed again by subtracting one column from the other at the peaks — the three largest samples in the window, figures rounded:&lt;/p>
&lt;pre tabindex="0">&lt;code>peak 1 ~160 MB | flush 1.0 s | replay 1.0 s | delta: fractions of a millisecond
peak 2 ~100 MB | flush 0.7 s | replay 0.7 s | delta: fractions of a millisecond
peak 3 ~95 MB | flush 1.7 s | replay 1.7 s | delta: fractions of a millisecond
&lt;/code>&lt;/pre>&lt;p>Applying WAL adds fractions of a millisecond. The two cron runs caught in that window sit exactly a quarter of an hour apart, which is the cron job signing its work.&lt;/p>
&lt;h2 id="what-lag-in-the-patroni-health-check-actually-guards">What lag= in the Patroni health check actually guards&lt;/h2>
&lt;p>The &lt;code>lag=&lt;/code> parameter covers exactly one scenario, &amp;ldquo;replica alive but behind,&amp;rdquo; because role and state are checked independently of it. The source is more useful than the documentation here: the docs describe what the endpoint is for, the code describes what it does. From &lt;code>patroni/api.py&lt;/code> in &lt;a href="https://github.com/patroni/patroni/blob/v3.0.2/patroni/api.py">v3.0.2&lt;/a>:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">6
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">7
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-python" data-lang="python">&lt;span style="display:flex;">&lt;span>max_replica_lag &lt;span style="color:#ff79c6">=&lt;/span> parse_int(self&lt;span style="color:#ff79c6">.&lt;/span>path_query&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;lag&amp;#39;&lt;/span>, [sys&lt;span style="color:#ff79c6">.&lt;/span>maxsize])[&lt;span style="color:#bd93f9">0&lt;/span>], &lt;span style="color:#f1fa8c">&amp;#39;B&amp;#39;&lt;/span>)
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#ff79c6">if&lt;/span> max_replica_lag &lt;span style="color:#ff79c6">is&lt;/span> &lt;span style="color:#ff79c6">None&lt;/span>:
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> max_replica_lag &lt;span style="color:#ff79c6">=&lt;/span> sys&lt;span style="color:#ff79c6">.&lt;/span>maxsize
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>is_lagging &lt;span style="color:#ff79c6">=&lt;/span> leader_optime &lt;span style="color:#ff79c6">and&lt;/span> leader_optime &lt;span style="color:#ff79c6">&amp;gt;&lt;/span> replayed_location &lt;span style="color:#ff79c6">+&lt;/span> max_replica_lag
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>replica_status_code &lt;span style="color:#ff79c6">=&lt;/span> &lt;span style="color:#bd93f9">200&lt;/span> &lt;span style="color:#ff79c6">if&lt;/span> &lt;span style="color:#ff79c6">not&lt;/span> patroni&lt;span style="color:#ff79c6">.&lt;/span>noloadbalance &lt;span style="color:#ff79c6">and&lt;/span> &lt;span style="color:#ff79c6">not&lt;/span> is_lagging &lt;span style="color:#ff79c6">and&lt;/span> \
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> response&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;role&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">==&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;replica&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">and&lt;/span> response&lt;span style="color:#ff79c6">.&lt;/span>get(&lt;span style="color:#f1fa8c">&amp;#39;state&amp;#39;&lt;/span>) &lt;span style="color:#ff79c6">==&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;running&amp;#39;&lt;/span> &lt;span style="color:#ff79c6">else&lt;/span> &lt;span style="color:#bd93f9">503&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Two things fall out of those few lines. First, &lt;code>parse_int(..., 'B')&lt;/code>: the threshold is bytes, full stop. There&amp;rsquo;s no time-based variant of this endpoint, so the entire discussion about &amp;ldquo;what number should we put there&amp;rdquo; was unanswerable until somebody said the units out loud. Second, &lt;code>is_lagging&lt;/code> sits in a chain with &lt;code>role&lt;/code> and &lt;code>state&lt;/code>, each able to produce a 503 on its own. A genuinely broken replica gets caught by &lt;code>state&lt;/code>/&lt;code>role&lt;/code> anyway: when streaming breaks, the row just vanishes from &lt;code>pg_stat_replication&lt;/code> and there&amp;rsquo;s no byte figure left to compute.&lt;/p>
&lt;p>There&amp;rsquo;s a third detail that makes the threshold shakier than it looks. &lt;code>leader_optime&lt;/code> is read from etcd, refreshed once per &lt;code>loop_wait&lt;/code> — 10 seconds by default (&lt;a href="https://patroni.readthedocs.io/en/latest/SETTINGS.html">Patroni settings&lt;/a>) — so the byte figure being compared can be up to a &lt;code>loop_wait&lt;/code> stale before you even pick a number to compare it against. Against bursts that come and go in a few seconds, the measurement interval is longer than the event.&lt;/p>
&lt;p>Prometheus history sizes the one scenario the threshold does cover (&lt;code>pg_replication_lag_seconds&lt;/code>, 15 s scrape). Over a month, lag exceeded one minute exactly once: the single real failure in that window ran for hours, when the replica&amp;rsquo;s VM came back from a hypervisor reboot without network.&lt;/p>
&lt;p>&lt;strong>A couple of seconds in the worst normal case against hours for a real failure. Nearly four orders of magnitude between them, and the threshold was sitting right up against the lower bound.&lt;/strong> A threshold that far from the signal it&amp;rsquo;s supposed to catch isn&amp;rsquo;t &amp;ldquo;tuned strictly,&amp;rdquo; it&amp;rsquo;s tuned arbitrarily. The &lt;code>group_vars&lt;/code> file even carries a comment from whoever raised it earlier, explaining that the bump was meant to avoid false DOWNs during checkpoint and autovacuum. Against a ~170 MB burst, no value in the tens of megabytes was ever going to work.&lt;/p>
&lt;h2 id="why-pg_replication_lag_seconds-lies-when-nobody-is-writing">Why pg_replication_lag_seconds lies when nobody is writing&lt;/h2>
&lt;p>On a replica, postgres_exporter computes this metric as &lt;code>now() - pg_last_xact_replay_timestamp()&lt;/code>, so during write silence it grows on its own without the replica being behind by anything. Its p99 over the month runs to tens of seconds, and reading that as &amp;ldquo;the replica served data tens of seconds stale&amp;rdquo; is wrong: it means the last transaction it replayed was that old, because no newer transaction existed. On the primary the same metric is flat zero. Only direct &lt;code>replay_lag&lt;/code> sampling from the primary is trustworthy.&lt;/p>
&lt;p>The second limitation is resolution. At a 15-second scrape interval, a burst that lasts a few seconds is invisible by construction. For short events the instrument is the HAProxy log, not the dashboard.&lt;/p>
&lt;h2 id="reading-burst-length-out-of-the-haproxy-log">Reading burst length out of the HAProxy log&lt;/h2>
&lt;p>When monitoring resolution is too coarse, the health check itself is a measuring instrument with known timing. With &lt;code>inter 3s fall 3 rise 2&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>&lt;code>DOWN&lt;/code> is declared after &lt;code>fall 3&lt;/code> × &lt;code>inter 3s&lt;/code> = 9 seconds above threshold.&lt;/li>
&lt;li>&lt;code>UP&lt;/code> returns after &lt;code>rise 2&lt;/code> × &lt;code>inter 3s&lt;/code> = 6 seconds below it.&lt;/li>
&lt;li>Therefore burst length ≈ DOWN duration + 3 s.&lt;/li>
&lt;/ul>
&lt;p>Applied to thirty-odd ejections over a bit more than a week:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>DOWN lasted&lt;/th>
&lt;th>Share of ejections&lt;/th>
&lt;th>Burst was&lt;/th>
&lt;th>Survives &lt;code>fall 5&lt;/code>?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>6 s&lt;/td>
&lt;td>about two thirds&lt;/td>
&lt;td>~9 s&lt;/td>
&lt;td>no — eliminated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9 s&lt;/td>
&lt;td>a couple of cases&lt;/td>
&lt;td>~12 s&lt;/td>
&lt;td>no — eliminated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>18 s&lt;/td>
&lt;td>a couple of cases&lt;/td>
&lt;td>~21 s&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>21 s&lt;/td>
&lt;td>roughly a fifth&lt;/td>
&lt;td>~24 s&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>27 s&lt;/td>
&lt;td>one case&lt;/td>
&lt;td>~30 s&lt;/td>
&lt;td>yes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two thirds of them lasted the minimum possible DOWN, meaning the burst barely crossed the threshold and was already over. Raising &lt;code>fall 3&lt;/code> → &lt;code>fall 5&lt;/code> requires 15 consecutive seconds above threshold and removes around 70% of the ejections, with no staleness risk given the numbers above. The remaining third ran 21–30 seconds and would still eject.&lt;/p>
&lt;p>To be clear about status: that change is argued for with the numbers above and agreed in principle, but it is not applied. It sits behind the same sign-off as anything else touching the balancer, and the residual third of the ejections needs either a higher byte threshold or the time-based check I keep coming back to.&lt;/p>
&lt;p>That arithmetic required no new tooling, no sampling, and no agreement from anyone. The data had been in the balancer log the whole time.&lt;/p>
&lt;h2 id="what-i-shipped-and-how-the-rollout-went-sideways">What I shipped, and how the rollout went sideways&lt;/h2>
&lt;p>One line removed from the read backend, leaving the failover-critical backends untouched:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">5
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span> backend slave
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> option httpchk GET /replica?lag=10485760 ...
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span> http-check expect status 200
&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">- default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">&lt;/span>&lt;span style="color:#50fa7b;font-weight:bold">+ default-server inter 3s fall 3 rise 2
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The HAProxy 2.4 manual describes the option plainly: with &lt;code>shutdown-sessions&lt;/code>, &amp;ldquo;all connections to the server are immediately terminated when the server goes down&amp;rdquo; (&lt;a href="https://docs.haproxy.org/2.4/configuration.html">configuration manual, §5.2&lt;/a>). On a read backend fronting a replica that comes back within seconds, that is damage with no upside. In the logs it killed dozens of sessions at once. The application does not see &lt;code>40001&lt;/code> for those connections, it sees a severed socket, which the developer&amp;rsquo;s interceptor never catches. The replica can still be ejected during a burst; live reads now finish, and new connections fall through to &lt;code>use_backend master if { nbsrv(slave) eq 0 }&lt;/code>. The option stays in the &lt;code>master&lt;/code> and &lt;code>long_queries&lt;/code> backends, where killing sessions during failover is the point.&lt;/p>
&lt;p>Three things about the rollout worth stealing:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>--check --diff&lt;/code> on the whole playbook, not just my part.&lt;/strong> Someone had added &lt;code>weight 80&lt;/code> / &lt;code>weight 20&lt;/code> by hand on the server and never put them in the template. A normal run would have silently erased them. I pulled the repository up to the server&amp;rsquo;s actual state first, then applied my change on top.&lt;/li>
&lt;li>&lt;strong>A &lt;code>group_vars&lt;/code> file turned out to be a hardlink shared by several inventories.&lt;/strong> One edit, every environment changed.&lt;/li>
&lt;li>&lt;strong>I rolled it out with a restart instead of a reload.&lt;/strong> In master-worker mode &lt;code>kill -USR2&lt;/code> to the master reloads without dropping anything: the master holds the sockets, the port never closes, and the old worker drains its sessions. A restart dropped every connection at once — which is precisely the damage this change exists to prevent. Shipping a fix for connection drops by dropping every connection is the kind of thing you only do once.&lt;/li>
&lt;/ul>
&lt;h2 id="why-patroni-ignores-the-log-settings-in-your-playbook">Why Patroni ignores the log settings in your playbook&lt;/h2>
&lt;p>Anything under &lt;code>bootstrap.dcs&lt;/code> is applied once, at cluster initialization, and edits to it afterwards do nothing. I found this while looking for something else entirely: &lt;code>pg_log&lt;/code> had never rotated. Tens of gigabytes on the primary, single-digit gigabytes on the replica, files that had been piling up for years, on a cluster whose Ansible template configures rotation perfectly well.&lt;/p>
&lt;p>Nobody had edited anything by hand this time. The Patroni documentation states the rule with unusual force: &amp;ldquo;Once Patroni has initialized the cluster for the first time and settings have been stored in the DCS, all future changes to the &lt;code>bootstrap.dcs&lt;/code> section of the YAML configuration will not take any effect!&amp;rdquo; (&lt;a href="https://patroni.readthedocs.io/en/latest/yaml_configuration.html">YAML configuration&lt;/a>). The section &amp;ldquo;will be written into &lt;code>/&amp;lt;namespace&amp;gt;/&amp;lt;scope&amp;gt;/config&lt;/code> of the given configuration store after initializing the new cluster,&amp;rdquo; and from that moment the copy in etcd is the one that counts.&lt;/p>
&lt;p>Changing those values later goes through &lt;code>patronictl edit-config&lt;/code> or the REST API, not the file (&lt;a href="https://patroni.readthedocs.io/en/latest/dynamic_configuration.html">dynamic configuration&lt;/a>). Which means a Patroni cluster has a whole class of settings where the playbook is documentation rather than configuration, and &lt;code>--check --diff&lt;/code> will never tell you, because the file on disk matches the template exactly. It is right and the cluster still ignores it.&lt;/p>
&lt;h2 id="what-the-application-layer-got-wrong">What the application layer got wrong&lt;/h2>
&lt;p>The interceptor I was asked to review was correct; the defects were around it. Findings handed back to the developers:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>ERROR&lt;/code> and &lt;code>FATAL&lt;/code> are different events.&lt;/strong> &lt;code>ERROR: canceling statement due to conflict with recovery&lt;/code> cancels the statement and leaves the connection usable. &lt;code>FATAL: terminating connection due to conflict with recovery&lt;/code> does not. Over the week and a half of logs, nearly all the events were &lt;code>ERROR&lt;/code> and a handful were &lt;code>FATAL&lt;/code>. One retry strategy cannot serve both.&lt;/li>
&lt;li>&lt;strong>The retry loop was unreachable&lt;/strong> past its first iteration, so it retried once regardless of configuration.&lt;/li>
&lt;li>&lt;strong>The application under-reports by half.&lt;/strong> Over two weeks the database logged roughly twice as many conflict events as the application reported through its single &lt;code>Log::warning&lt;/code>. That gap is why nobody knew the size of the problem.&lt;/li>
&lt;li>&lt;strong>&lt;code>application_name&lt;/code> is empty&lt;/strong> in every event, so a conflict cannot be attributed to a service from the database logs — only to a database user.&lt;/li>
&lt;li>&lt;strong>A pre-existing lazy-connection bug in the Laravel wrapper.&lt;/strong> &lt;code>bindValues()&lt;/code> opens with &lt;code>$this-&amp;gt;getPdo()-&amp;gt;getAttribute(...)&lt;/code>, and &lt;code>getPdo()&lt;/code> resolves the lazy closure and opens a connection to the primary. Every query with bindings calls it, read-only ones included, so the read/write split silently leaks connections to the primary on any parameterised read.&lt;/li>
&lt;/ol>
&lt;p>Half of my initial review comments on the interceptor did not survive my own re-checking. Separating &amp;ldquo;I would have written this differently&amp;rdquo; from &amp;ldquo;this is a defect&amp;rdquo; is most of what code review is, and I am not always good at it.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Count the events before theorizing: split &lt;code>ERROR&lt;/code> from &lt;code>FATAL&lt;/code> in the PostgreSQL log and get the rate per day.&lt;/li>
&lt;li>Correlate every symptom stream by timestamp on one timezone scale, first, before building any causal story.&lt;/li>
&lt;li>Sample &lt;code>pg_stat_replication&lt;/code> from the primary once per second; compare &lt;code>replay_lsn&lt;/code> against &lt;code>flush_lsn&lt;/code> to separate transport lag from apply lag.&lt;/li>
&lt;li>Sample the &lt;code>*_lag&lt;/code> interval columns to get lag in seconds, which is the unit your requirement is almost certainly written in.&lt;/li>
&lt;li>Read your health-check endpoint&amp;rsquo;s source for the units it actually accepts before negotiating a threshold value.&lt;/li>
&lt;li>Derive burst duration from the balancer&amp;rsquo;s own &lt;code>inter&lt;/code>/&lt;code>fall&lt;/code>/&lt;code>rise&lt;/code> timing when scrape intervals are too coarse.&lt;/li>
&lt;li>Check whether &lt;code>hot_standby_feedback&lt;/code> is safe &lt;em>for your topology&lt;/em>: with slots and one replica, it is not.&lt;/li>
&lt;li>Run &lt;code>--check --diff&lt;/code> across the whole playbook before applying, and reload rather than restart.&lt;/li>
&lt;li>For anything Patroni owns, read the live values with &lt;code>patronictl show-config&lt;/code> rather than trusting the YAML in your repository.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>Neither of the two canonical answers to &lt;code>conflict with recovery&lt;/code> was usable on this cluster, and both failures were topology-specific rather than wrong in general. &lt;code>hot_standby_feedback&lt;/code> assumes you have no slots or a spare replica; raising the streaming delay assumes nothing downstream measures replay position. Check those assumptions against your own cluster before pasting either one into &lt;code>postgresql.conf&lt;/code>.&lt;/p>
&lt;p>The larger lesson is about units. The health check measured &amp;ldquo;how much WAL has not arrived&amp;rdquo; in bytes while the business requirement said &amp;ldquo;how stale is the data I am serving&amp;rdquo; in seconds. Every discussion about the right threshold was unanswerable until somebody wrote both sentences down next to each other. The measured answer was single-digit milliseconds at the median against a real failure measured in hours — and the deferred work, an &lt;code>agent-check&lt;/code> agent reporting &lt;code>up&lt;/code>/&lt;code>down&lt;/code> from &lt;code>now() - pg_last_xact_replay_timestamp()&lt;/code>, is now a decision that can be made with numbers instead of a guess.&lt;/p></content:encoded></item><item><title>Gmail bounced everything the relay sent: three broken auth mechanisms and a silent OpenDKIM</title><link>https://bitpage.me/incidents/gmail-bounces-everything-spf-dkim-fcrdns/</link><pubDate>Thu, 06 Aug 2026 07:08:22 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/incidents/gmail-bounces-everything-spf-dkim-fcrdns/</guid><description>A relay with more than two years of uptime failed nearly every Gmail delivery because SPF, DKIM and forward-confirmed reverse DNS were broken independently. The root cause of the DKIM half was one word: tables mounted as refile: instead of file:, which makes OpenDKIM drop the key lookup and sign nothing without logging a line.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> Three independent mechanisms were broken at once, and fixing any one of them changed nothing visible. The domain had no &lt;code>v=spf1&lt;/code> record (only a dead SenderID one), the PTR resolved forward to a different server, and OpenDKIM was signing nothing because &lt;code>KeyTable&lt;/code> and &lt;code>SigningTable&lt;/code> were attached as &lt;code>refile:&lt;/code> while their contents were in plain two-column format. That last one cost the most time: when OpenDKIM can&amp;rsquo;t find a key it logs absolutely nothing, so the daemon reads &lt;code>active&lt;/code>, the config parses, the key is readable, and mail still leaves unsigned.&lt;/p>
&lt;p>The server is an outgoing relay for transactional mail from a couple of dozen application hosts: password resets, notifications, alerts. Inbound mail for the domain goes elsewhere, to Google Workspace as MX. Postfix on a legacy container with more than two years of uptime, OpenDKIM 2.10.3, no Ansible, edits by hand only. The daily log summary read a four-digit &lt;code>bounced&lt;/code> against a single-digit &lt;code>sent&lt;/code>: thousands of rejections, a handful of deliveries. Password reset mail was among the casualties, so people could not get back into their accounts. Nobody had changed anything on this box in years, which turned out to be the whole story.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Root cause (DKIM):&lt;/strong> &lt;code>KeyTable&lt;/code>/&lt;code>SigningTable&lt;/code> mounted as &lt;code>refile:&lt;/code> while the entries were plain &lt;code>example.org mail._domainkey.example.org&lt;/code> lines. Under &lt;code>refile:&lt;/code> the key is a regex matched against the whole &lt;code>From:&lt;/code> address, so &lt;code>example.org&lt;/code> never matches &lt;code>noreply@example.org&lt;/code>. Changing one word per line to &lt;code>file:&lt;/code> fixed it.&lt;/li>
&lt;li>&lt;strong>Also broken:&lt;/strong> &lt;code>InternalHosts&lt;/code> listed fewer than ten addresses against a Postfix &lt;code>mynetworks&lt;/code> two to three times longer. Mail from the app hosts was being verified instead of signed.&lt;/li>
&lt;li>&lt;strong>Also broken:&lt;/strong> the only SPF-shaped TXT record was &lt;code>spf2.0/mfrom,pra&lt;/code> SenderID. RFC 7208 discards anything that doesn&amp;rsquo;t begin with &lt;code>v=spf1&lt;/code>, so the domain effectively had no SPF record.&lt;/li>
&lt;li>&lt;strong>Also broken:&lt;/strong> the PTR pointed at a hostname whose A record led to a different machine. Forward-confirmed reverse DNS failed.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> &lt;code>grep -c DKIM-Signature /var/log/mail.log&lt;/code> returns &lt;code>0&lt;/code> whether signing works or not. Postfix does not write message headers to the log.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> testing with &lt;code>sendmail&lt;/code> from the mail server itself passes, because &lt;code>127.0.0.1&lt;/code> was in &lt;code>InternalHosts&lt;/code>. Real traffic from other hosts kept going out bare.&lt;/li>
&lt;/ul>
&lt;h2 id="why-a-server-nobody-touched-stopped-being-deliverable">Why a server nobody touched stopped being deliverable&lt;/h2>
&lt;p>The requirements moved while the configuration stood still. Gmail began requiring authentication from bulk senders in February 2024 and has been ramping enforcement since, and a config that predated those rules simply stopped qualifying (&lt;a href="https://support.google.com/mail/answer/81126">Email sender guidelines&lt;/a>). There was no change window to roll back to, no deploy to blame. The rejection came back verbatim as:&lt;/p>
&lt;pre tabindex="0">&lt;code>550-5.7.26 Your email has been blocked because the sender is unauthenticated.
550-5.7.26 Gmail requires all senders to authenticate with either SPF or DKIM.
550-5.7.26 Authentication results:
550-5.7.26 DKIM = did not pass
550-5.7.26 SPF [example.org] with ip: [203.0.113.51] = did not pass
&lt;/code>&lt;/pre>&lt;p>Both named mechanisms failing at once should have been the tell that this was not one bug. I read it as one bug anyway and spent the next hour paying for it.&lt;/p>
&lt;h2 id="three-of-the-four-facts-in-my-own-notes-were-wrong">Three of the four facts in my own notes were wrong&lt;/h2>
&lt;p>I started from a ticket written a day earlier that already contained a diagnosis, and rechecking it by hand killed three of its four claims. The notes said: PTR is fine and forward and reverse match; DNS is hosted on Cloudflare; DMARC is absent.&lt;/p>
&lt;p>The PTR pointed at &lt;code>legacy.example.org&lt;/code>, whose A record led to &lt;code>198.51.100.216&lt;/code>, a different server entirely. &lt;code>dig NS&lt;/code> returned &lt;code>ns-*.awsdns-*&lt;/code>, so DNS was on Route 53, not Cloudflare. DMARC was present and had been all along, &lt;code>v=DMARC1; p=none&lt;/code>. One claim out of four survived. These were my own notes, from the previous day.&lt;/p>
&lt;p>The other rejection message had been telling me about the PTR the whole time:&lt;/p>
&lt;pre tabindex="0">&lt;code>550-5.7.25 [203.0.113.51] The IP address sending this message does not have a PTR
550-5.7.25 record setup, or the corresponding forward DNS entry does not match the sending IP
&lt;/code>&lt;/pre>&lt;p>The operative half is the second clause. Not &amp;ldquo;you have no PTR record&amp;rdquo; but &amp;ldquo;the forward entry doesn&amp;rsquo;t match&amp;rdquo;. I had read to the first familiar phrase, seen a PTR existed, and moved on. RFC 1912 §2.1 states the rule plainly: &amp;ldquo;Make sure your PTR and A records match. For every IP address, there should be a matching PTR record in the in-addr.arpa domain&amp;rdquo; (&lt;a href="https://www.rfc-editor.org/rfc/rfc1912.txt">RFC 1912&lt;/a>). The same section warns that getting this wrong &amp;ldquo;can cause loss of Internet services similar to not being registered in the DNS at all&amp;rdquo;, which is a fair description of a bounce column four digits wide.&lt;/p>
&lt;p>The fix had two possible shapes. Editing the A record of &lt;code>legacy.example.org&lt;/code> to point here would have satisfied the check, but that hostname is live and fronts an unrelated service, so the blast radius covered somebody else&amp;rsquo;s system. Repointing the PTR to &lt;code>mail.example.org&lt;/code>, whose A record already resolved to the sending IP, changed nothing outside this server. I also set Postfix &lt;code>myhostname&lt;/code> to &lt;code>mail.example.org&lt;/code> so the HELO name matches the PTR.&lt;/p>
&lt;h2 id="why-the-spf-record-that-existed-didnt-count">Why the SPF record that existed didn&amp;rsquo;t count&lt;/h2>
&lt;p>There was a TXT record on the domain, and it was worth exactly nothing, because it was a SenderID record rather than an SPF one. It began &lt;code>spf2.0/mfrom,pra&lt;/code>. RFC 7208 §4.5 is unambiguous about what a verifier does with that: &amp;ldquo;Starting with the set of records that were returned by the lookup, discard records that do not begin with a version section of exactly &amp;lsquo;v=spf1&amp;rsquo;&amp;rdquo; (&lt;a href="https://www.rfc-editor.org/rfc/rfc7208.txt">RFC 7208&lt;/a>).&lt;/p>
&lt;p>Discarded means the domain had no SPF record at all as far as Gmail was concerned, which is precisely what &lt;code>SPF [example.org] ... = did not pass&lt;/code> was reporting. A record that looks like SPF to a human and is invisible to every verifier is worse than an empty zone, because it stops anyone from looking further. Published in its place:&lt;/p>
&lt;pre tabindex="0">&lt;code>v=spf1 ip4:203.0.113.51 include:_spf.google.com include:spf.example.net ~all
&lt;/code>&lt;/pre>&lt;h2 id="why-grepping-the-mail-log-for-dkim-signature-proves-nothing">Why grepping the mail log for DKIM-Signature proves nothing&lt;/h2>
&lt;p>Postfix does not write message headers to its log, so the count is zero whether signing works or not. My first check was &lt;code>grep -c &amp;quot;DKIM-Signature&amp;quot; /var/log/mail.log&lt;/code>, which returned &lt;code>0&lt;/code>, and I took that as evidence that nothing was being signed. It wasn&amp;rsquo;t evidence of anything. The method cannot distinguish a broken signer from a working one, and I used it as the basis for the next hour of work.&lt;/p>
&lt;p>The check that does work is to send a message to a local mailbox and read the headers of the delivered file, &lt;code>/var/mail/nobody&lt;/code>. That shows the actual &lt;code>DKIM-Signature&lt;/code> header or its absence, with no inference in between. Building that first would have made every subsequent change a one-minute yes or no. I built it fourth.&lt;/p>
&lt;h2 id="opendkim-reports-active-parses-clean-and-signs-nothing">OpenDKIM reports active, parses clean, and signs nothing&lt;/h2>
&lt;p>OpenDKIM writes no log line when it fails to find a signing key for a message, so &lt;code>systemctl is-active&lt;/code> returning &lt;code>active&lt;/code> alongside a clean startup is zero evidence that signing happens. I found and fixed two genuine defects on the strength of that assumption, and neither produced a signature.&lt;/p>
&lt;p>The first was the config itself. &lt;code>/etc/opendkim.conf&lt;/code> had been truncated to four lines, with no &lt;code>Mode&lt;/code>, no &lt;code>KeyTable&lt;/code>, no &lt;code>SigningTable&lt;/code>. The fingerprints of a package upgrade were right there: &lt;code>/etc/opendkim/&lt;/code> had been touched recently while the files inside it were years older. I restored the missing directives and restarted. Clean start, no errors, no signature.&lt;/p>
&lt;p>The second was key ownership. The private key belonged to &lt;code>_apt:uuidd&lt;/code> with mode &lt;code>0600&lt;/code>, and the daemon runs as &lt;code>opendkim&lt;/code>. Checking as the service user rather than as root is the only way to see this:&lt;/p>
&lt;pre tabindex="0">&lt;code>su -s /bin/sh opendkim -c &amp;#34;head -c 20 /etc/opendkim/default/mail.private&amp;#34;
head: cannot open &amp;#39;/etc/opendkim/default/mail.private&amp;#39; for reading: Permission denied
&lt;/code>&lt;/pre>&lt;p>A real defect, and &lt;code>chown opendkim:opendkim&lt;/code> is mandatory. The key became readable. Still no signature. Two genuine bugs repaired, both necessary, neither sufficient, and my supply of hypotheses was gone.&lt;/p>
&lt;h2 id="the-actual-root-cause-was-one-word-and-it-was-in-the-man-page">The actual root cause was one word, and it was in the man page&lt;/h2>
&lt;p>The tables were attached as &lt;code>refile:&lt;/code> while their contents were in plain two-column format, and under &lt;code>refile:&lt;/code> the lookup key is treated as a regex matched against the entire &lt;code>From:&lt;/code> address. I stopped guessing and opened &lt;code>man opendkim.conf&lt;/code> on the server itself, same 2.10.3 as the running binary, which is more authoritative than whatever version the web is serving. The answer sits in one paragraph about dataset types:&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;If this table specifies a regular expression file (&amp;ldquo;refile&amp;rdquo;), then the keys are wildcard patterns that are matched against the address found in the From: header field. For all other database types, the full user@host is checked first, then simply host, then user@.domain (with all superdomains checked in sequence…)&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>The tables held lines like this:&lt;/p>
&lt;pre tabindex="0">&lt;code>example.org mail._domainkey.example.org
&lt;/code>&lt;/pre>&lt;p>Under &lt;code>refile:&lt;/code>, the pattern &lt;code>example.org&lt;/code> is matched against &lt;code>noreply@example.org&lt;/code> and does not match, so no key is found and the message leaves unsigned without a single log line. Under &lt;code>file:&lt;/code>, the lookup falls through to the second step, &amp;ldquo;then simply host&amp;rdquo;, where &lt;code>example.org&lt;/code> matches exactly. The fix was one word in two lines:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">4
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-diff" data-lang="diff">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">-KeyTable refile:/etc/opendkim/KeyTable
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">-SigningTable refile:/etc/opendkim/SigningTable
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f55">&lt;/span>&lt;span style="color:#50fa7b;font-weight:bold">+KeyTable file:/etc/opendkim/KeyTable
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#50fa7b;font-weight:bold">+SigningTable file:/etc/opendkim/SigningTable
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>The other direction works too: rewrite every table entry to &lt;code>*@example.org&lt;/code> and keep &lt;code>refile:&lt;/code>. I rejected it because the tables covered several domains, and rewriting every line of data is a wider edit than changing the dataset type to the one that already matches the format on disk.&lt;/p>
&lt;h2 id="the-second-half-of-the-same-bug-internalhosts-was-a-fraction-of-mynetworks">The second half of the same bug: InternalHosts was a fraction of mynetworks&lt;/h2>
&lt;p>OpenDKIM only signs mail arriving from hosts in &lt;code>InternalHosts&lt;/code>; everything else it verifies instead, so any app server missing from that list was sending bare mail by design. The man page states the whole behaviour in one line, describing the option as identifying &amp;ldquo;a set internal hosts whose mail should be signed rather than verified&amp;rdquo; (typo included, as printed in 2.10.3).&lt;/p>
&lt;p>&lt;code>InternalHosts&lt;/code> pointed at &lt;code>TrustedHosts&lt;/code>, which listed fewer than ten addresses. Postfix &lt;code>mynetworks&lt;/code> listed two to three times as many. Syncing the short list against the long one tripled it, finally covering the application hosts that had been relaying through a server which was, on paper, configured to sign their mail. The two files are not required to match exactly, since they answer different questions, but a host that can relay and cannot be signed is almost always an oversight rather than a decision.&lt;/p>
&lt;p>This is also why my manual test had lied to me earlier. Mail sent with &lt;code>sendmail&lt;/code> from the relay itself originates from &lt;code>127.0.0.1&lt;/code>, which was in the list, so a signature appeared and I briefly thought the job was done. The traffic that actually mattered came from the application hosts, none of them in the file. Test along the path production traffic takes, not the path that is convenient from an SSH session.&lt;/p>
&lt;h2 id="the-four-defects-and-the-check-that-actually-proves-each-one">The four defects and the check that actually proves each one&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Broken&lt;/th>
&lt;th>Symptom you see&lt;/th>
&lt;th>Check that settles it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>No &lt;code>v=spf1&lt;/code> record, SenderID only&lt;/td>
&lt;td>&lt;code>SPF … = did not pass&lt;/code>&lt;/td>
&lt;td>&lt;code>dig +short TXT example.org&lt;/code> — the string must begin &lt;code>v=spf1&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PTR forward mismatch&lt;/td>
&lt;td>&lt;code>550-5.7.25 … forward DNS entry does not match&lt;/code>&lt;/td>
&lt;td>&lt;code>dig -x &amp;lt;ip&amp;gt;&lt;/code>, then resolve that name&amp;rsquo;s A record back — it must return &lt;code>&amp;lt;ip&amp;gt;&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>refile:&lt;/code> on plain-format tables&lt;/td>
&lt;td>Nothing. No log line at all.&lt;/td>
&lt;td>Read the &lt;code>DKIM-Signature&lt;/code> header of a delivered message&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>InternalHosts&lt;/code> narrower than &lt;code>mynetworks&lt;/code>&lt;/td>
&lt;td>Signs locally, unsigned from apps&lt;/td>
&lt;td>Send from a real application host, not from the relay&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The third row is the one worth internalising. Three of these four failures announce themselves somewhere. The dataset-type bug produces no error, no warning, and a healthy-looking service, and the only way to observe it is to inspect the output rather than the process.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Build the verification path before touching anything: send to a local mailbox and read the delivered headers. Every later change then gives an immediate yes or no.&lt;/li>
&lt;li>Recheck every fact you were handed, including notes you wrote yourself yesterday.&lt;/li>
&lt;li>Read the whole SMTP rejection. &lt;code>5.7.25&lt;/code> and &lt;code>5.7.26&lt;/code> name different failures, and in both the specificity lives in the second clause of the sentence.&lt;/li>
&lt;li>Resolve the sending IP with &lt;code>dig -x&lt;/code>, then resolve the answer forward. Both directions, or it doesn&amp;rsquo;t count.&lt;/li>
&lt;li>Confirm the TXT record begins with &lt;code>v=spf1&lt;/code> rather than merely mentioning SPF.&lt;/li>
&lt;li>For DKIM: confirm the dataset type matches the table format, confirm the service user can read the key with &lt;code>su -s /bin/sh opendkim -c &amp;quot;…&amp;quot;&lt;/code>, and confirm &lt;code>InternalHosts&lt;/code> covers every host in &lt;code>mynetworks&lt;/code>.&lt;/li>
&lt;li>Send the final test from a host that is not the mail server.&lt;/li>
&lt;/ol>
&lt;p>The end state was &lt;code>DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=mail&lt;/code> in the delivered headers, &lt;code>spf=pass&lt;/code>, &lt;code>dmarc=pass&lt;/code>, and &lt;code>250 2.0.0 OK&lt;/code> from Gmail. Total time was about an hour and a half, most of it spent on the silent one.&lt;/p>
&lt;h2 id="what-authentication-didnt-fix-six-messages-in-ten-still-bounce">What authentication didn&amp;rsquo;t fix: six messages in ten still bounce&lt;/h2>
&lt;p>Passing SPF and DKIM gets mail accepted, not trusted, and this domain&amp;rsquo;s reputation problem survived every fix above untouched. A full day of counters after the fix still showed more rejections than deliveries — roughly six messages in ten going nowhere — and among them close to a hundred a day addressed to &lt;code>@iclaud.com&lt;/code>, a typo of &lt;code>@icloud.com&lt;/code> that the application has been faithfully retrying for years. Bounce rate is not the metric Google publishes a threshold for; that one is spam rate, which bulk senders are asked to keep under 0.30% in Postmaster Tools. They are different numbers, but they feed the same reputation ledger, and 0.30% is a useful sense of the scale a receiver considers acceptable. Mail with flawless authentication still lands in the spam folder if the domain spends years hammering addresses that don&amp;rsquo;t exist.&lt;/p>
&lt;p>The reason nobody knew is that the feedback loop had been cut at both ends. The server delivered non-delivery reports to the sender address, whose domain it considered local via &lt;code>mydestination&lt;/code>, and no local user by that name existed, so the reports were discarded on arrival. That was hundreds of destroyed reports a day. The application mails a dead address, the report about it is deleted by the same server, nobody finds out, and the application keeps mailing.&lt;/p>
&lt;p>What I deliberately did not do was tighten DMARC past &lt;code>p=none&lt;/code>. Moving to &lt;code>quarantine&lt;/code> before the statistics are clean would route legitimate mail to spam by my own hand, and there is no rush. Still outstanding: the envelope sender equals the &lt;code>From:&lt;/code> address, where a dedicated bounce address with VERP belongs; certbot 0.12.0 is dead on this box, still calling the retired &lt;code>acme-v01&lt;/code> endpoint, with a certificate that expired years ago. And every change here was made by hand on a container that lives outside Ansible, so none of it exists in any repository. The next person to open this server will find no trace of why it works.&lt;/p>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>When a daemon reports &lt;code>active&lt;/code> and does not do its job while logging nothing, stop repairing hypotheses and go read the man page for the semantics of the specific option you are relying on. Silence is not the absence of a fault. It is its own class of behaviour, and it is always described somewhere in the documentation, usually in a sentence about how lookups are performed.&lt;/p>
&lt;p>The process mistake was ordering. I fixed three genuine defects (truncated config, key ownership, then the host list) before finding the one that actually mattered, and each time I expected the next test to pass. A reliable check built at minute one, reading the headers of a delivered message, would have turned each of those into a one-minute negative result instead of an hour of accumulating false hope. Build the instrument before you start turning things.&lt;/p></content:encoded></item><item><title>How to migrate a stateful Docker app off a server with a dying disk</title><link>https://bitpage.me/infra/migrate-docker-app-dying-disk/</link><pubDate>Fri, 24 Jul 2026 11:35:00 +0000</pubDate><dc:creator>BitPage</dc:creator><guid>https://bitpage.me/infra/migrate-docker-app-dying-disk/</guid><description>Treat a disk that fails on writes as read-only: stream pg_dump over SSH to the target, pull EOL images from the registry instead of rebuilding them, and anchor rsync excludes with a leading slash. Notes from moving a Docker app of some fifteen services with a PostgreSQL database on the order of ten gigabytes.</description><content:encoded>&lt;p>&lt;strong>Short answer:&lt;/strong> When the source server&amp;rsquo;s disk fails on writes but still reads fine, treat it as read-only hardware. Stream &lt;code>pg_dump -Fc&lt;/code> over SSH straight to the target machine, pull Docker images from your registry instead of rebuilding end-of-life Dockerfiles, and anchor every rsync exclude with a leading slash. That moved an app of some fifteen services, with a PostgreSQL database on the order of ten gigabytes, onto a new node without a single write to the failing disk.&lt;/p>
&lt;p>The source was an old OpenVZ box with a degraded RAID and a disk that had quietly stopped accepting writes. Reads were healthy. On it lived a Dockerized PHP application: some fifteen services (nginx, php-fpm, a pool of queue workers, PostgreSQL, Redis), a database on the order of ten gigabytes, a few gigabytes of media. The job was to stand up a production-like copy on a new Proxmox node for testing, with the real traffic cutover scheduled later.&lt;/p>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Fix:&lt;/strong> run &lt;code>pg_dump -Fc&lt;/code> on the source but pipe it through &lt;code>ssh -T&lt;/code> to the target&amp;rsquo;s disk — the source only reads, the target does all the writing.&lt;/li>
&lt;li>&lt;strong>Fix:&lt;/strong> don&amp;rsquo;t rebuild images based on EOL distributions; pull the exact production images from the registry, reusing &lt;code>/root/.docker/config.json&lt;/code> from the source host.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> &lt;code>rsync --exclude=log/&lt;/code> without a leading slash matches &lt;code>log&lt;/code> at every depth. It silently removed &lt;code>vendor/yiisoft/yii2/log/&lt;/code> from the copy and the app answered HTTP 500.&lt;/li>
&lt;li>&lt;strong>Trap:&lt;/strong> a dump to a dying disk hangs without an error. Watch the output file&amp;rsquo;s size delta, not the process list.&lt;/li>
&lt;/ul>
&lt;h2 id="why-pg_dump-hangs-on-a-dying-disk-instead-of-failing">Why pg_dump hangs on a dying disk instead of failing&lt;/h2>
&lt;p>A dump writing to a dead disk doesn&amp;rsquo;t error out; it stops making progress while the process stays alive. My first attempt was the obvious one: &lt;code>pg_dump -Fc&lt;/code> into a local &lt;code>backup/&lt;/code> directory on the source. The file grew to roughly a gigabyte and froze. Two size checks six seconds apart returned the same size, with the process still running. The disk had died on the write path specifically, and a hung dump was exactly how that looked from userspace.&lt;/p>
&lt;p>The liveness check lied to me too. The watcher used &lt;code>pgrep -f pg_dump&lt;/code>, and its own command line contained the string &lt;code>pg_dump&lt;/code>, so it matched itself and kept reporting the dump as alive. &lt;code>ps -C pg_dump&lt;/code> (exact process name match) or a size delta on the output file are the checks that don&amp;rsquo;t fool themselves.&lt;/p>
&lt;h2 id="streaming-the-dump-keep-every-write-off-the-source">Streaming the dump: keep every write off the source&lt;/h2>
&lt;p>The fix is to leave the source with the read half of the work and move the write half to another machine. &lt;code>pg_dump&lt;/code> runs on the source, but its output goes over SSH to the target&amp;rsquo;s healthy disk:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">2
&lt;/span>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>ssh -T old-host &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &lt;span style="color:#f1fa8c">&amp;#39;docker exec app-postgres pg_dump -Fc -U app appdb&amp;#39;&lt;/span> &lt;span style="color:#f1fa8c">\
&lt;/span>&lt;/span>&lt;/span>&lt;span style="display:flex;">&lt;span>&lt;span style="color:#f1fa8c">&lt;/span> &amp;gt; /srv/dumps/appdb.dump
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Dumping from a live database is fine here. The PostgreSQL 11 documentation states it directly: &amp;ldquo;pg_dump does not block other users accessing the database (readers or writers)&amp;rdquo; (&lt;a href="https://www.postgresql.org/docs/11/app-pgdump.html">pg_dump reference&lt;/a>).&lt;/p>
&lt;p>Two details matter for the transport. &lt;code>ssh -T&lt;/code> disables pseudo-terminal allocation, and &lt;code>docker exec&lt;/code> must run without &lt;code>-t&lt;/code>: a tty mangles the binary custom-format stream. With both in place the gigabyte-and-a-half dump completed with exit code 0, and on the target &lt;code>pg_restore --no-owner -j 4 -d appdb&lt;/code> unpacked it back into the same ten gigabytes or so, roughly a hundred tables, with the &lt;code>rum&lt;/code> extension intact.&lt;/p>
&lt;h2 id="pull-eol-images-dont-rebuild-them">Pull EOL images, don&amp;rsquo;t rebuild them&lt;/h2>
&lt;p>If production images already exist in a registry, pull them; rebuilding their Dockerfiles years later is a dead end. My first &lt;code>docker compose pull&lt;/code> on the new host failed with &lt;code>Error response from daemon: error from registry: access forbidden&lt;/code> — a private GitLab registry the new machine wasn&amp;rsquo;t authorized against. Instead of fixing auth, I detoured into rebuilding the images from the same Dockerfiles production once used. That went like this:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Base image&lt;/th>
&lt;th>Debian release inside&lt;/th>
&lt;th>What apt says in 2026&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>nginx:1.17.4&lt;/code>&lt;/td>
&lt;td>buster&lt;/td>
&lt;td>&lt;code>Release 404 Not Found&lt;/code> on deb.debian.org&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>postgres:11&lt;/code>&lt;/td>
&lt;td>stretch&lt;/td>
&lt;td>PGDG packages for PostgreSQL 11 removed, 404&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Buster I could route to &lt;a href="https://www.debian.org/distrib/archive">archive.debian.org&lt;/a>. Then the stretch-based &lt;code>postgres:11&lt;/code> build died on the PGDG repository, which no longer serves packages for an EOL major at all. I stopped there and admitted the detour was me solving the wrong problem.&lt;/p>
&lt;p>The actual answer was sitting on the dying host the whole time: it pulls those images itself, which means working registry credentials live in its &lt;code>/root/.docker/config.json&lt;/code> (that&amp;rsquo;s where &lt;a href="https://docs.docker.com/reference/cli/docker/login/">&lt;code>docker login&lt;/code>&lt;/a> stores them). Copying that one file to the new host let &lt;code>docker compose pull&lt;/code> fetch the whole set of images — the exact bytes production runs, no build step.&lt;/p>
&lt;h2 id="the-rsync-exclude-that-deleted-framework-files">The rsync exclude that deleted framework files&lt;/h2>
&lt;p>&lt;code>--exclude=log/&lt;/code> without a leading slash matches every directory named &lt;code>log&lt;/code> at any depth, not just the top-level one you meant. I added it to skip a couple of gigabytes of application logs during the code-and-media rsync. The copy finished clean, and the app came up with HTTP 500:&lt;/p>
&lt;pre tabindex="0">&lt;code>include(/app/vendor/yiisoft/yii2/log/Logger.php):
failed to open stream: No such file or directory
&lt;/code>&lt;/pre>&lt;p>Nothing in that message points at rsync. A missing framework file reads like a corrupted vendor directory, and I chased that theory first. The real cause: the unanchored pattern had stripped &lt;code>vendor/yiisoft/yii2/log/&lt;/code> out of the transfer. The &lt;a href="https://download.samba.org/pub/rsync/rsync.1">rsync man page&lt;/a> is explicit about the rule: &amp;ldquo;if the pattern starts with a / then it is anchored to a particular spot in the hierarchy of files, otherwise it is matched against the end of the pathname.&amp;rdquo;&lt;/p>
&lt;p>The fix is one character: &lt;code>--exclude=/log/&lt;/code> anchors the pattern to the transfer root. A second rsync run then sent only the &lt;code>log&lt;/code> directories it had wrongly skipped inside the code, a few seconds of incremental transfer.&lt;/p>
&lt;h2 id="testing-against-live-integrations-without-side-effects">Testing against live integrations without side effects&lt;/h2>
&lt;p>Queue workers and cron are what produce outbound effects; a web tier on top of a copied database is safe to start. This app talks to a real payment provider, CRM, SMTP, and ClickHouse, and the test copy had to come up without firing any of that. The compose setup was already split into &lt;code>docker-compose.yml&lt;/code> (web) and &lt;code>docker-compose.queue.yml&lt;/code> (workers and scheduler), so the whole safety switch was one environment variable:&lt;/p>
&lt;div class="highlight">&lt;div style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">
&lt;table style="border-spacing:0;padding:0;margin:0;border:0;">&lt;tr>&lt;td style="vertical-align:top;padding:0;margin:0;border:0;">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code>&lt;span style="white-space:pre;-webkit-user-select:none;user-select:none;margin-right:0.4em;padding:0 0.4em 0 0.4em;color:#7f7f7f">1
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td style="vertical-align:top;padding:0;margin:0;border:0;;width:100%">
&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;">&lt;code class="language-bash" data-lang="bash">&lt;span style="display:flex;">&lt;span>&lt;span style="color:#8be9fd;font-style:italic">COMPOSE_FILE&lt;/span>&lt;span style="color:#ff79c6">=&lt;/span>docker/docker-compose.yml docker compose up -d
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>Web and API up, every queue worker and the scheduler down, database an isolated copy. Smoke test: API returned 200, the site returned its usual 302, the admin backend rendered the login page.&lt;/p>
&lt;p>One integration still broke: ClickHouse refused connections from the new node&amp;rsquo;s IP (&amp;ldquo;Connection refused&amp;rdquo;), and backend pages that touch it answered 503. External allowlists (database, mail, analytics) are part of the migration checklist, not something to discover during cutover.&lt;/p>
&lt;h2 id="step-by-step">Step by step&lt;/h2>
&lt;ol>
&lt;li>Establish the disk&amp;rsquo;s failure mode first: &amp;ldquo;reads work, writes hang&amp;rdquo; changes every decision after it.&lt;/li>
&lt;li>Copy &lt;code>/root/.docker/config.json&lt;/code> from the source to the target; &lt;code>docker compose pull&lt;/code> the production images.&lt;/li>
&lt;li>Stream the database: &lt;code>ssh -T source 'docker exec db pg_dump -Fc -U app appdb' &amp;gt; dump&lt;/code> on the target.&lt;/li>
&lt;li>Restore with &lt;code>pg_restore --no-owner -j 4 -d appdb&lt;/code>.&lt;/li>
&lt;li>Rsync code and media with anchored excludes only (&lt;code>--exclude=/log/&lt;/code>, not &lt;code>--exclude=log/&lt;/code>).&lt;/li>
&lt;li>Bring up the web tier alone via &lt;code>COMPOSE_FILE&lt;/code>, leaving queues and cron down.&lt;/li>
&lt;li>Smoke-test, and collect every external service that needs the new IP allowlisted.&lt;/li>
&lt;li>Cut over later in a quiet window: fresh dump, DNS, allowlists.&lt;/li>
&lt;/ol>
&lt;h2 id="bottom-line">Bottom line&lt;/h2>
&lt;p>Dying hardware changes the rules: the source is read-only whether you accept it or not, so structure the whole migration around that. Stream the dump instead of writing it locally, pull images instead of rebuilding EOL bases, and anchor rsync excludes before they cut into &lt;code>vendor/&lt;/code>. The copy went from &amp;ldquo;disk is dying&amp;rdquo; to a responding production clone in a few hours, and most of the lost time was me fighting battles (EOL apt archives, a phantom vendor corruption) that the constraints had already decided.&lt;/p></content:encoded></item></channel></rss>