<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: bishwajit mandal</title>
    <description>The latest articles on DEV Community by bishwajit mandal (@bishwajit_mndl).</description>
    <link>https://dev.to/bishwajit_mndl</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2327533%2Ffb02e7b9-5f27-49a0-9d0e-dec9c603add9.png</url>
      <title>DEV Community: bishwajit mandal</title>
      <link>https://dev.to/bishwajit_mndl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bishwajit_mndl"/>
    <language>en</language>
    <item>
      <title>ZFS dataset shadowing hid 5.5TB from me during an outage</title>
      <dc:creator>bishwajit mandal</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:39:05 +0000</pubDate>
      <link>https://dev.to/bishwajit_mndl/zfs-dataset-shadowing-hid-55tb-from-me-during-an-outage-2gfi</link>
      <guid>https://dev.to/bishwajit_mndl/zfs-dataset-shadowing-hid-55tb-from-me-during-an-outage-2gfi</guid>
      <description>&lt;p&gt;My reverse proxy container stopped starting one evening, which meant every service behind it stopped resolving. Jellyfin, Immich, Nextcloud, the dashboard — all of it sits behind Nginx Proxy Manager in CT104, so when CT104 stays down, the whole *.bmlab.co.in namespace is down with it.&lt;/p&gt;

&lt;p&gt;It took me a few hours to find the actual cause, and the thing that cost me the most time was a warning that had nothing to do with it. Writing it up because the final root cause is a ZFS behaviour I hadn't run into before, and because I made a couple of wrong calls along the way that are worth showing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single mini PC running Proxmox 8.x. Seven LXC containers and one VM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CT100 — NAS-ish container, Samba and Docker&lt;/li&gt;
&lt;li&gt;CT101 — *arr stack&lt;/li&gt;
&lt;li&gt;CT102 — Immich&lt;/li&gt;
&lt;li&gt;CT103 — Nextcloud&lt;/li&gt;
&lt;li&gt;CT104 — Nginx Proxy Manager, Homarr, cloudflared&lt;/li&gt;
&lt;li&gt;CT105 — Jellyfin&lt;/li&gt;
&lt;li&gt;CT106 — Pi-hole&lt;/li&gt;
&lt;li&gt;VM108 — Tailscale exit node&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two ZFS pools: flash, a 2TB SSD, and vault, a single 8TB IronWolf holding media and downloads. Split-horizon DNS via Pi-hole, wildcard Let's Encrypt cert terminated at NPM.&lt;/p&gt;

&lt;p&gt;One host. No redundancy. That matters later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The red herring&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;pct start 104 failed. The log had an AppArmor line in it — a complaint about lxc.apparmor.profile — sitting near the top of the output, which is exactly where you look first.&lt;/p&gt;

&lt;p&gt;I spent real time on that. Profile syntax, whether unconfined was still valid on this kernel, whether a Proxmox update had tightened something. All of it was wasted. The warning was noise that had been in that config for a long time and had never stopped the container before.&lt;/p&gt;

&lt;p&gt;The lesson I keep re-learning: a warning that is present during a failure is not the same as the cause of the failure. The question to ask is "was this here yesterday, when it worked?" It was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual cause&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Further down, past the AppArmor line, the mount of the container's rootfs was failing.&lt;/p&gt;

&lt;p&gt;CT104's rootfs was a raw image file on vault, mounted through a loop device with ext4 on it. ext4 has a feature called multi-mount protection (MMP), which exists to stop two hosts mounting the same shared filesystem at once. It works by writing a heartbeat block to the filesystem on mount and re-writing it periodically.&lt;/p&gt;

&lt;p&gt;The mount was failing at that write.&lt;/p&gt;

&lt;p&gt;Not because of any shared-storage conflict. Because there was nowhere to put it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;zfs list vault
&lt;span class="go"&gt;NAME    USED  AVAIL  REFER  MOUNTPOINT
vault  7.27T    36M   ...   /vault
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;36 megabytes free. A filesystem that cannot write a single block cannot complete an ext4 mount, so the container could not start. The whole domain went down because a pool hit 100%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the pool was full&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two causes, both mine.&lt;/p&gt;

&lt;p&gt;Sparse raw images over-provisioned past the pool. CT100 and CT104 both had raw disk images on vault. Their apparent sizes added up to roughly 9.24T on a pool with 7.27T usable. Sparse allocation means this is allowed right up until the moment it isn't, and nothing warns you on the way there.&lt;/p&gt;

&lt;p&gt;Downloads were being copied, not hardlinked. Sonarr and Radarr were configured such that an imported file became a second full copy rather than a hardlink to the original. About 2.4T of the pool was the same media twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part I actually want to write about&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is where it got strange. I went looking for what was consuming the space, and du on the obvious paths didn't account for anywhere near the full pool.&lt;/p&gt;

&lt;p&gt;/vault/media/movies looked nearly empty. So did /vault/media/tv and /vault/downloads/complete. But the pool insisted the space was gone.&lt;/p&gt;

&lt;p&gt;The cause was dataset shadowing. I had created child datasets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vault/downloads
vault/downloads/complete
vault/media
vault/media/movies
vault/media/tv

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

&lt;/div&gt;



&lt;p&gt;Each child mounts at its own path inside the parent's directory tree. If content was written into /vault/media/movies before the child dataset vault/media/movies was created and mounted over it, that content still exists — it lives in the parent dataset, underneath the mountpoint. Once the child is mounted on top, the parent's copy is invisible. Every normal tool, du and ls and ncdu included, sees the child dataset and reports what's in the child.&lt;/p&gt;

&lt;p&gt;About 5.5TB of real content was sitting underneath those mountpoints, occupying space, unreachable by path.&lt;/p&gt;

&lt;p&gt;To see it, you bind-mount the parent dataset somewhere else. The bind exposes the parent's own directory tree without the children mounted over it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/raw-dl
mount &lt;span class="nt"&gt;--bind&lt;/span&gt; /vault/downloads /mnt/raw-dl
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; /mnt/raw-dl/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Important detail I got wrong first: you bind the parent dataset, not the child. Binding vault/downloads/complete just shows you the child again. Binding vault/downloads shows you what the parent holds at that path with nothing shadowing it.&lt;/p&gt;

&lt;p&gt;I will also admit that mid-debug I talked myself out of this diagnosis and decided the shadowing wasn't real, because the child datasets looked fine when I checked them directly. That was the wrong call and it cost me another pass. The shadowing was real. It's just that you cannot see it from the path you'd naturally check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two more things I got wrong&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;du numbers on sparse files are apparent size, not allocated blocks. I estimated how much I'd reclaim by deleting things, and the estimate was badly off against what ZFS actually freed. For sparse images, du --apparent-size and reality diverge a lot. Check zfs list after deleting rather than trusting arithmetic done in advance.&lt;/p&gt;

&lt;p&gt;I had never taken a backup. More on that below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Free space.&lt;/strong&gt; Deleted verified duplicate downloads — the ones that had a matching file already in the library. Several hundred gigabytes of TV, plus some remuxes I had twice. Also found an &lt;code&gt;.exe&lt;/code&gt; sitting in a downloads folder that had no business being there. Got &lt;code&gt;vault&lt;/code&gt; to 726G free, 90% used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Move rootfs volumes off the pool that filled.&lt;/strong&gt; CT100 and CT104's root disks went from directory storage on &lt;code&gt;vault&lt;/code&gt; to ZFS subvolumes on &lt;code&gt;flash&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pct move-volume 104 rootfs flash
pct move-volume 100 rootfs flash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reverse proxy no longer depends on the media pool having free space. That was the single most valuable change of the night.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Stop backing up the media mounts.&lt;/strong&gt; CT100 had a 7850G mount point and a 200G one, both flagged for backup, which makes any vzdump attempt absurd. &lt;code&gt;backup=0&lt;/code&gt; on both in the container config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Create a backup target and actually use it.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zfs create &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;refquota&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;400G flash/backups
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Added as directory storage &lt;code&gt;flash-backups&lt;/code&gt; in Proxmox, then ran vzdump for CT100, CT103, CT104 and CT106.&lt;/p&gt;

&lt;p&gt;Those were the first backups this system has ever had. It had been running for over a year. I had built split-horizon DNS, a wildcard cert, a Tailscale exit node and a seven-container stack, and had never once run &lt;code&gt;vzdump&lt;/code&gt;. That is the real finding of this whole incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Removed the AppArmor &lt;code&gt;unconfined&lt;/code&gt; line&lt;/strong&gt; from CT104's config, since it was doing nothing except drawing my attention during an outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verifying it end to end&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rather than trusting the browser and its cache, I checked the proxy path directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; jellyfin.bmlab.co.in:443:192.168.0.205 &lt;span class="se"&gt;\&lt;/span&gt;
  https://jellyfin.bmlab.co.in
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTTP/2 302 back from openresty, wildcard cert valid. That confirms DNS resolution, TLS termination and the proxy host entry in one call, without any client-side DNS in the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still open&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Deliberately listing these unfinished, because the writeup isn't the fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hardlinks are still not enabled in Sonarr/Radarr. Settings → Media Management → Use Hardlinks instead of Copy. Until that's on, the 2.4T problem regenerates.&lt;/li&gt;
&lt;li&gt;No qBittorrent auto-removal by ratio or seed time yet.&lt;/li&gt;
&lt;li&gt;vzdump is not on a schedule. I ran it by hand.&lt;/li&gt;
&lt;li&gt;Some files in vault are owned by a UID outside the unprivileged container's mapping range and need fixing.&lt;/li&gt;
&lt;li&gt;The now-empty child datasets need destroying once I've confirmed nothing lives in them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I'd tell past me&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Alert on pool capacity. A threshold at 80% would have turned this outage into a Tuesday afternoon task.&lt;/li&gt;
&lt;li&gt;Don't put an infrastructure container's rootfs on your bulk storage pool. The service that fixes everything else should not depend on the pool most likely to fill.&lt;/li&gt;
&lt;li&gt;If du and zpool disagree by terabytes, suspect shadowed datasets before you suspect either tool.&lt;/li&gt;
&lt;li&gt;Create datasets before you write data into the path, not after.&lt;/li&gt;
&lt;li&gt;Take a backup. I know. I know.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>proxmox</category>
      <category>zfs</category>
      <category>linux</category>
      <category>homelab</category>
    </item>
  </channel>
</rss>
