<?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: Igor Sheko</title>
    <description>The latest articles on DEV Community by Igor Sheko (@irbisadm).</description>
    <link>https://dev.to/irbisadm</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%2F4235240%2F9dd1ef13-d185-4f6f-8873-fb796395b3be.png</url>
      <title>DEV Community: Igor Sheko</title>
      <link>https://dev.to/irbisadm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/irbisadm"/>
    <language>en</language>
    <item>
      <title>Out of inodes with 56 GB free - moving source trees into squashfs</title>
      <dc:creator>Igor Sheko</dc:creator>
      <pubDate>Sat, 07 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/irbisadm/out-of-inodes-with-56-gb-free-moving-source-trees-into-squashfs-3n7i</link>
      <guid>https://dev.to/irbisadm/out-of-inodes-with-56-gb-free-moving-source-trees-into-squashfs-3n7i</guid>
      <description>&lt;p&gt;&lt;a href="https://webrtc.page/" rel="noopener noreferrer"&gt;webrtc.page&lt;/a&gt; is a set of MCP servers for reading real-time media codebases: libwebrtc, pion, coturn, Kamailio, Asterisk, FreeSWITCH, Janus and a few more. Each server keeps several releases of its project on disk, with a semantic index and a tree-sitter symbol index next to the source. libwebrtc is the big one: 32 Chrome milestones, the newest of them with their full &lt;code&gt;DEPS&lt;/code&gt; checkout.&lt;/p&gt;

&lt;p&gt;On 7 November 2026 I deployed M157 and the site went down. This post is about why, the fixes I shipped the same day, and the change that came out of it: every source tree is now a single squashfs image instead of a directory with a few hundred thousand files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure
&lt;/h2&gt;

&lt;p&gt;The deploy script packs changed versions into a tar archive, uploads it while the services are still running, then stops pm2, removes the old data and extracts the archive. The extraction step printed thousands of lines like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tar: ./versions/M157/src/third_party/blink/web_tests/fast/dom/Element/...: Cannot open: No space left on device
tar: Exiting with failure status due to previous errors
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script stopped there, with pm2 already stopped. Every endpoint returned 502 from 17:52 to 18:11 UTC, about 19 minutes, until a second deploy finished.&lt;/p&gt;

&lt;p&gt;The disk-space check before the upload had passed. It said 59 GB would be free after extraction, and &lt;code&gt;df -h&lt;/code&gt; agreed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/dev/sda 199G 133G 56G 71% /mnt/volume_lon1_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;df -i&lt;/code&gt; did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/dev/sda 6553600 6553600 0 100% /mnt/volume_lon1_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data volume is ext4 with the default ratio of one inode per 32 KB, which gives 6.55 million inodes for 199 GB. Source code is millions of small files. The volume ran out of files long before it ran out of bytes, and my pre-flight check only counted bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the inodes went
&lt;/h2&gt;

&lt;p&gt;I counted files per directory on the server:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;what&lt;/th&gt;
&lt;th&gt;files&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;one libwebrtc milestone with deps&lt;/td&gt;
&lt;td&gt;375k–396k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;of which own WebRTC code&lt;/td&gt;
&lt;td&gt;~19k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pion (210 release tags)&lt;/td&gt;
&lt;td&gt;647k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kamailio, FreeSWITCH, sipXcom, Asterisk (all versions)&lt;/td&gt;
&lt;td&gt;284k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a libwebrtc version is about 5% WebRTC and 95% &lt;code&gt;third_party&lt;/code&gt;. That is expected once you look at &lt;code&gt;DEPS&lt;/code&gt;: the entry &lt;code&gt;src/third_party&lt;/code&gt; is Chromium's entire &lt;code&gt;third_party&lt;/code&gt; repository, not a list of the libraries WebRTC links. Inside it, &lt;code&gt;blink/web_tests&lt;/code&gt;, &lt;code&gt;perf_tests&lt;/code&gt; and &lt;code&gt;manual_tests&lt;/code&gt; alone were 203k files in M157. None of them were in the semantic index.&lt;/p&gt;

&lt;p&gt;Two more things came out of the count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orphaned dependency trees.&lt;/strong&gt; Locally I keep deps only for the newest versions and strip them from older ones with a &lt;code&gt;compact&lt;/code&gt; command. On the server, 16 versions still had their &lt;code&gt;third_party&lt;/code&gt;, not 7. &lt;code&gt;compact&lt;/code&gt; deletes files but does not touch the version's &lt;code&gt;updatedAt&lt;/code&gt;, and the incremental deploy only re-uploads versions whose &lt;code&gt;updatedAt&lt;/code&gt; changed. So every compaction since the start stayed local, and the server kept nine trees of ~380k files that nobody had on disk anymore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.git&lt;/code&gt; directories.&lt;/strong&gt; Each DEPS checkout is a shallow git clone with its own &lt;code&gt;.git&lt;/code&gt;. The deploy excluded only the top-level &lt;code&gt;./.git&lt;/code&gt;, so 45 nested ones per version, about 1.3 GB, went to production with every release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same-day fixes
&lt;/h2&gt;

&lt;p&gt;To bring the site back I removed the half-extracted M157 and the orphaned &lt;code&gt;third_party&lt;/code&gt; trees by hand, then re-ran the deploy. After that I made three changes so this would not happen again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An inode check before anything is stopped.&lt;/strong&gt; The deploy now counts entries in the archive and compares them to &lt;code&gt;df --output=iavail&lt;/code&gt; on the server, keeping 200k spare. If the archive does not fit, the deploy fails while the old version is still serving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Junk never reaches the disk.&lt;/strong&gt; One list, &lt;code&gt;PRUNE_PATHS&lt;/code&gt;, names the paths not worth keeping. The downloader turns it into a non-cone sparse checkout for the dependency that contains them, so they are never written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/*
!/blink/web_tests/
!/blink/perf_tests/
!/blink/manual_tests/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same list is used to prune versions that are already on disk, to exclude the paths from the archive, and to delete them on the server before the inode check. A version with deps went from ~396k files to ~193k.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;compact&lt;/code&gt; follows the deploy.&lt;/strong&gt; Compaction now bumps &lt;code&gt;updatedAt&lt;/code&gt;, so the deploy re-uploads the version without deps and with the smaller index. The deploy also re-uploads any version that has &lt;code&gt;third_party&lt;/code&gt; on the server but not locally, whatever its &lt;code&gt;updatedAt&lt;/code&gt; says.&lt;/p&gt;

&lt;p&gt;After the next deploy the volume was at 41% inodes (2.65M used) and 75 GB of disk. A good result, but the shape of the problem had not changed: one inode per source file, and every new milestone adds another ~190k.&lt;/p&gt;

&lt;h2&gt;
  
  
  Options
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;option&lt;/th&gt;
&lt;th&gt;inodes&lt;/th&gt;
&lt;th&gt;disk&lt;/th&gt;
&lt;th&gt;cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bigger volume, or &lt;code&gt;mkfs -i&lt;/code&gt; with more inodes&lt;/td&gt;
&lt;td&gt;more of them&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;money; migration of 133 GB; same growth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XFS (allocates inodes dynamically)&lt;/td&gt;
&lt;td&gt;no fixed limit&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;migration; the files are still there&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hardlink duplicates across versions&lt;/td&gt;
&lt;td&gt;shared per file&lt;/td&gt;
&lt;td&gt;shared&lt;/td&gt;
&lt;td&gt;breaks with tar deploys; fragile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;one squashfs image per tree&lt;/td&gt;
&lt;td&gt;1 per version&lt;/td&gt;
&lt;td&gt;compressed&lt;/td&gt;
&lt;td&gt;mount management&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hardlink idea was tempting: &lt;code&gt;third_party/abseil-cpp&lt;/code&gt; in M156 and M157 is byte-for-byte identical, all 1,655 files. But hardlinks only survive inside one tar archive, and the deploy ships one version at a time. Keeping them on the server would mean a dedup pass after each deploy.&lt;/p&gt;

&lt;p&gt;squashfs is a read-only, compressed filesystem in a single file. The kernel mounts it with a loop device; without root there is &lt;a href="https://github.com/vasi/squashfuse" rel="noopener noreferrer"&gt;squashfuse&lt;/a&gt;. My versions are already read-only in practice: a milestone is downloaded once, indexed once, and then only read. An image fits that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measurements
&lt;/h2&gt;

&lt;p&gt;Before changing any code I packed M157 (deps, after pruning) and M150 (no deps) on my workstation: 20 cores, NVMe, mksquashfs 4.7.5, squashfuse 0.6.3. &lt;code&gt;.git&lt;/code&gt; was excluded from the image.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tree&lt;/th&gt;
&lt;th&gt;files&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;th&gt;zstd-3&lt;/th&gt;
&lt;th&gt;zstd-15&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;M157&lt;/td&gt;
&lt;td&gt;190,823&lt;/td&gt;
&lt;td&gt;3.3 GB&lt;/td&gt;
&lt;td&gt;795 MB in 5.0 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;710 MB in 25.8 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M150&lt;/td&gt;
&lt;td&gt;18,521&lt;/td&gt;
&lt;td&gt;276 MB&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54 MB in 2.0 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reading is what the servers do all day, mostly ripgrep over the whole tree and single-file reads. Times are on a warm page cache unless marked "first run":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;operation on M157&lt;/th&gt;
&lt;th&gt;directory&lt;/th&gt;
&lt;th&gt;squashfuse&lt;/th&gt;
&lt;th&gt;squashfuse_ll&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rg -c RTC_GUARDED_BY&lt;/code&gt; over the tree&lt;/td&gt;
&lt;td&gt;0.17 s&lt;/td&gt;
&lt;td&gt;17–23 s&lt;/td&gt;
&lt;td&gt;2.1 s first run, 0.65 s after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rg&lt;/code&gt; in &lt;code&gt;pc/&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;0.01 s&lt;/td&gt;
&lt;td&gt;0.02 s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;find -type f&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.06 s&lt;/td&gt;
&lt;td&gt;1.44 s&lt;/td&gt;
&lt;td&gt;0.18 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plain &lt;code&gt;squashfuse&lt;/code&gt; is single-threaded, and a full-tree search through it is 100 times slower than the directory. &lt;code&gt;squashfuse_ll&lt;/code&gt;, the low-level multithreaded driver from the same project, is within 4–12x. The compression level did not change read speed (zstd-3 and zstd-15 both gave 2.1 s and 0.65–0.68 s), so I took zstd-15 for the smaller files.&lt;/p&gt;

&lt;p&gt;On the server I do not need FUSE at all. The kernel there (Ubuntu 24.04, 6.8) has squashfs built in with &lt;code&gt;CONFIG_SQUASHFS_ZSTD=y&lt;/code&gt;, and the services run as root, so a kernel loop mount uses the normal page cache and needs no daemon in a 4 GB box.&lt;/p&gt;

&lt;p&gt;I repeated the test on the server with M150, the image placed on the same data volume as the directory, and the page cache dropped (&lt;code&gt;echo 3 &amp;gt; /proc/sys/vm/drop_caches&lt;/code&gt;) before each cold run:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;operation on M150, production&lt;/th&gt;
&lt;th&gt;directory&lt;/th&gt;
&lt;th&gt;kernel squashfs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rg&lt;/code&gt; over the tree, cold, run 1&lt;/td&gt;
&lt;td&gt;7.28 s&lt;/td&gt;
&lt;td&gt;2.21 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rg&lt;/code&gt; over the tree, cold, run 2&lt;/td&gt;
&lt;td&gt;4.67 s&lt;/td&gt;
&lt;td&gt;1.95 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rg&lt;/code&gt; over the tree, warm (3 runs)&lt;/td&gt;
&lt;td&gt;0.12–0.14 s&lt;/td&gt;
&lt;td&gt;0.11–0.12 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;read one file, cold&lt;/td&gt;
&lt;td&gt;0.014 s&lt;/td&gt;
&lt;td&gt;0.011 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here the image is 2.4–3.3x &lt;em&gt;faster&lt;/em&gt; cold, and the same warm. The data volume is DigitalOcean block storage, attached over the network. Reading the tree as a directory means 16k small files scattered across it; reading the image means 54 MB of compressed data laid out together. On local NVMe the image only costs; on network storage it pays for itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change
&lt;/h2&gt;

&lt;p&gt;Every source tree keeps its path. What used to be a directory is now an image next to a mount point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;versions/M157/src/ directory → mount point (read-only)
versions/M157/src.sqfs → image
versions/M157/semantic.db, ast.db → unchanged
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing that reads source had to change: ripgrep, file reads, path sanitising with &lt;code&gt;realpath&lt;/code&gt; and the AST indexer all see the same directory as before.&lt;/p&gt;

&lt;p&gt;The module is small. Packing is one mksquashfs call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;packArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;treeDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;treeDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-comp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zstd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-Xcompression-level&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-noappend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-quiet&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-no-progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-wildcards&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;... .git&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// nested .git dirs stay out&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mounting depends on who is asking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mountCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mnt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mount&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-t&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;squashfs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-o&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;loop,ro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mnt&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;squashfuse_ll&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mnt&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mounts are state outside the filesystem: a reboot or a deploy leaves empty mount points. Each MCP server therefore mounts the images of its own project when it starts. The call is idempotent, and a failed mount is logged and costs only those versions, not the server. The deploy also runs a &lt;code&gt;mount-all&lt;/code&gt; before starting pm2, so a failure shows up in its log.&lt;/p&gt;

&lt;p&gt;Three things broke on the way, all for the same reason: a mount point looks like a directory, so tools treat it as one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tar walks into mounts.&lt;/strong&gt; Packing a version whose &lt;code&gt;src/&lt;/code&gt; is mounted put all 190k files back into the archive. The deploy now excludes the mount point of every packed tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;du&lt;/code&gt; walks into mounts too.&lt;/strong&gt; The size estimate counted the uncompressed contents. It uses &lt;code&gt;du -x&lt;/code&gt; now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rm -rf&lt;/code&gt; cannot remove a read-only mount.&lt;/strong&gt; Replacing a version on the server first unmounts its trees, then deletes the directory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration avoided re-uploading whole versions. A version that is packed locally but is still a directory on the server ships only its &lt;code&gt;src.sqfs&lt;/code&gt;. The server deletes the old &lt;code&gt;src/&lt;/code&gt; directory, the image takes its place, and &lt;code&gt;semantic.db&lt;/code&gt; and &lt;code&gt;ast.db&lt;/code&gt; stay where they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;On my workstation, packing all 32 libwebrtc versions took about five minutes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inodes used (whole disk)&lt;/td&gt;
&lt;td&gt;8.82M&lt;/td&gt;
&lt;td&gt;7.18M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disk used&lt;/td&gt;
&lt;td&gt;512 GB&lt;/td&gt;
&lt;td&gt;481 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;libwebrtc source, 32 versions&lt;/td&gt;
&lt;td&gt;~1.6M files&lt;/td&gt;
&lt;td&gt;32 images, 6.16 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Images for versions with deps are 703–744 MB, without deps 50–60 MB.&lt;/p&gt;

&lt;p&gt;In production the migration shipped only the 32 images, a 7.2 GB archive of 38k entries. The upload took about an hour while the services kept serving. The server then deleted the old &lt;code&gt;src/&lt;/code&gt; directories, extracted the images and mounted them: 32 kernel loop mounts, no failures.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;production volume&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inodes used&lt;/td&gt;
&lt;td&gt;2.65M (41%)&lt;/td&gt;
&lt;td&gt;1.02M (16%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disk used&lt;/td&gt;
&lt;td&gt;75 GB&lt;/td&gt;
&lt;td&gt;44 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;libwebrtc source&lt;/td&gt;
&lt;td&gt;~1.6M files&lt;/td&gt;
&lt;td&gt;32 images, 5.8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;downtime&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;about 4 minutes (502s from 20:55 to 20:59 UTC)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most of those four minutes went into deleting 1.6 million files of the old trees. Future deploys of a packed version replace one file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other ten servers: line images
&lt;/h2&gt;

&lt;p&gt;libwebrtc was the obvious first target: 32 trees that held most of the inodes. The other servers looked different. pion had 209 release tags, each with &lt;code&gt;src/&lt;/code&gt; and &lt;code&gt;deps/&lt;/code&gt;, so 418 small trees of 2–3k files. The C servers had between 5 and 42 versions each. One image per tree would have meant about 570 mounts.&lt;/p&gt;

&lt;p&gt;Every kernel squashfs mount keeps its own caches and decompressor state. From the kernel's structure that is roughly 0.7 MB per mount: a metadata cache of 8 × 8 KB, a fragment cache of 3 × 128 KB, a 128 KB data page and the zstd workspace. That is an estimate, not a measurement, but 570 of them would pin about 400 MB that cannot be reclaimed, on a box with 4 GB of RAM. Locally it would also mean 570 FUSE processes.&lt;/p&gt;

&lt;p&gt;So for every server except libwebrtc the unit is a &lt;em&gt;line&lt;/em&gt;, not a version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pion/versions/.lines/v4.2.sqfs mounted at pion/versions/.lines/v4.2/
  v4.2.0/src/… v4.2.0/deps/…
  v4.2.1/src/… …
pion/versions/v4.2.1/semantic.db, ast.db (unchanged)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pion lines are minor release lines, seven in total. The other servers are small enough for one line each. The function every server uses to find a version's source root now checks two places. A loose directory with a &lt;code&gt;src/&lt;/code&gt; wins, which covers a version that was just downloaded and not packed yet. Otherwise the path points into the line. Packing a line rebuilds it from the current image plus any loose versions, swaps the image, and deletes the loose trees.&lt;/p&gt;

&lt;p&gt;I expected lines to save mounts. They also saved a lot of space, because mksquashfs deduplicates identical files within an image by default, and consecutive releases of a project share most of their files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;server&lt;/th&gt;
&lt;th&gt;versions&lt;/th&gt;
&lt;th&gt;files&lt;/th&gt;
&lt;th&gt;line images&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pion&lt;/td&gt;
&lt;td&gt;209&lt;/td&gt;
&lt;td&gt;335,320&lt;/td&gt;
&lt;td&gt;7 images, 33 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sipXcom&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;57,833&lt;/td&gt;
&lt;td&gt;190 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kamailio&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;90,123&lt;/td&gt;
&lt;td&gt;24 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FreeSWITCH&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;72,878&lt;/td&gt;
&lt;td&gt;52 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rtpengine&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;21,579&lt;/td&gt;
&lt;td&gt;19 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all 10 servers&lt;/td&gt;
&lt;td&gt;330&lt;/td&gt;
&lt;td&gt;~635k&lt;/td&gt;
&lt;td&gt;16 images, ~465 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;63 pion v3.1 releases fit in 6 MB. Packing all 16 lines on my workstation took 63 seconds.&lt;/p&gt;

&lt;p&gt;The deploy compares each line image's sha256 with the server's copy and ships only the ones that differ. The first run shipped all 16, a 1.3 GB archive (most of it the RFC index, which ships every time). Removing the 330 loose trees on the server happens after the services are back: until a loose &lt;code&gt;src/&lt;/code&gt; is gone the servers read it, and once it is gone they read the line. Each removal also checks first that the version is visible inside its mounted line, so a line that failed to mount leaves the old directories alone. This deploy was down for about two minutes (502s from 21:25 to 21:26 UTC).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;production volume&lt;/th&gt;
&lt;th&gt;after libwebrtc&lt;/th&gt;
&lt;th&gt;after all servers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;inodes used&lt;/td&gt;
&lt;td&gt;1.02M (16%)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25.5k (0.4%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disk used&lt;/td&gt;
&lt;td&gt;44 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;33 GB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;squashfs mounts&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Pros and cons
&lt;/h2&gt;

&lt;p&gt;What I got:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One inode per version&lt;/strong&gt; instead of up to ~190k. Inodes are no longer the limit; disk is, and slowly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4–5x less disk.&lt;/strong&gt; M157 went from 3.3 GB to 710 MB, M150 from 276 MB to 54 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;.git&lt;/code&gt; in production&lt;/strong&gt; , without a separate exclude rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A version deploys as one file.&lt;/strong&gt; No extraction of hundreds of thousands of files while the services are stopped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immutable trees.&lt;/strong&gt; A mounted image cannot be modified by a stray script, and the same file is what runs locally and in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster cold search in production.&lt;/strong&gt; On network block storage a cold whole-tree ripgrep went from 4.7–7.3 s to 1.9–2.2 s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slower full-tree search without root.&lt;/strong&gt; On my workstation, through &lt;code&gt;squashfuse_ll&lt;/code&gt;, a cold whole-tree ripgrep is about 12x slower than a directory, 4x warm. Searches scoped to a subdirectory do not notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One FUSE process per mount locally.&lt;/strong&gt; About 6 MB RSS idle, 36 MB after a full scan of M157. With 48 mounts that is fine; with one per tree it would not be, which is why small versions go into lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new release repacks its whole line.&lt;/strong&gt; For pion v4.2 that is 9 MB, for sipXcom 190 MB. Cheap at these sizes, and it grows with the line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mounts are state.&lt;/strong&gt; A reboot, or a deploy that fails between unmount and mount, leaves empty directories. The servers remount on start, but it is one more thing that can be wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing a version means repacking it.&lt;/strong&gt; Adding deps to an old version or pruning it now goes extract, modify, pack. The tools do this automatically, but it is slower than deleting a directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No incremental upload inside a version.&lt;/strong&gt; Any change to a tree produces a new image, uploaded whole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root on the server.&lt;/strong&gt; Kernel mounts need it. My services already run as root; if yours do not, you need a mount helper or FUSE there too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The data volume went from 6.55 million inodes used and a failed deploy to 25 thousand, and from 133 GB of disk to 33 GB. The source code did not change; only the way it sits on disk did.&lt;/p&gt;

&lt;p&gt;The broader lesson is about the check that failed. Disk space is the number everyone watches, and the deploy script watched it carefully. For a workload made of small files, inodes are a separate budget, and it runs out silently.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>sre</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
