<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="http://josh.pencheon.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="http://josh.pencheon.dev/" rel="alternate" type="text/html" /><updated>2026-01-01T14:03:56+00:00</updated><id>http://josh.pencheon.dev/feed.xml</id><title type="html">Josh Pencheon - Software Engineer</title><author><name>Josh Pencheon</name></author><entry><title type="html">CPU inference with Ollama</title><link href="http://josh.pencheon.dev/2025/09/21/cpu-inference-with-ollama.html" rel="alternate" type="text/html" title="CPU inference with Ollama" /><published>2025-09-21T00:00:00+00:00</published><updated>2025-09-21T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/09/21/cpu-inference-with-ollama</id><content type="html" xml:base="http://josh.pencheon.dev/2025/09/21/cpu-inference-with-ollama.html"><![CDATA[<p>I’ve been using <a href="https://ollama.com/">Ollama</a> for the past year or so to experiment with open-source large language models, and have most recently been trying out OpenAI’s <a href="https://ollama.com/library/gpt-oss">gpt-oss</a> 20 billion parameter model.</p>

<h2 id="the-hardware">The hardware</h2>

<p>Not having access to a GPU from the last decade, I’ve instead been running these models via Ollama’s CPU-only support. It has been interesting to see how recent library improvements have made <code class="language-plaintext highlighter-rouge">gpt-oss</code> much more useable when operating in this way.</p>

<p>Here’s the hardware I’m using:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ neofetch kernel cpu memory
kernel: 6.16.4-arch1-1
cpu: Intel Xeon E5-2698 v4 (40) @ 3.600GHz
memory: 15181MiB / 64208MiB
</code></pre></div></div>

<p>This is a Broadwell-EP CPU from early 2016, paired with quad-channel DDR4-2400. Memory bandwidth is king for LLM inference, and this hardware has a <em>theoretical</em> maximum of  a little under 80GB/s.</p>

<h2 id="the-test">The test</h2>

<p>I used the following prompt against the <code class="language-plaintext highlighter-rouge">lib/</code> source of <a href="https://github.com/joshpencheon/hobble">a small Ruby gem</a>, which resulted in 1.01k input tokens being fed to the model:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">MODEL</span><span class="o">=</span><span class="s2">"gpt-oss:20b"</span>
<span class="nv">GEM_CODE</span><span class="o">=</span><span class="si">$(</span>
  git ls-files <span class="nt">--</span> lib <span class="se">\</span>
  | xargs <span class="nt">-I</span> <span class="o">{}</span> bash <span class="nt">-c</span> <span class="s1">'echo -e "{} contains:\n$(cat {})"'</span>
<span class="si">)</span>
<span class="nb">echo</span> <span class="nt">-e</span> <span class="s2">"offer a refactoring suggestion for this ruby gem:</span><span class="se">\n\n</span><span class="si">$(</span>GEM_CODE<span class="si">)</span><span class="s2">"</span> <span class="se">\</span>
| ollama run <span class="nv">$MODEL</span> <span class="nt">--verbose</span> -
</code></pre></div></div>

<h2 id="the-results">The results</h2>

<p>A combination of Ollama enabling flash attention for CPU-only prompt processing, as well as efficiency improvements in the handling of the way <code class="language-plaintext highlighter-rouge">gpt-oss</code> model weights are stored resulted in a big performance improvement between 0.11.4 and 0.11.8:</p>

<div class="overflow-auto">

  <table>
    <thead>
      <tr>
        <th>Model</th>
        <th style="text-align: right">Ollama Version</th>
        <th style="text-align: right">Prompt Eval Rate (tokens/s)</th>
        <th style="text-align: right">Generation Rate (tokens/s)</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>gpt-oss:20b</td>
        <td style="text-align: right">0.11.4</td>
        <td style="text-align: right">10.17</td>
        <td style="text-align: right">6.16</td>
      </tr>
      <tr>
        <td>gpt-oss:20b</td>
        <td style="text-align: right">0.11.8</td>
        <td style="text-align: right">57.50</td>
        <td style="text-align: right">10.16</td>
      </tr>
      <tr>
        <td>qwen3-coder:latest</td>
        <td style="text-align: right">0.11.8</td>
        <td style="text-align: right">78.19</td>
        <td style="text-align: right">13.55</td>
      </tr>
      <tr>
        <td>qwen3:0.6b</td>
        <td style="text-align: right">0.11.8</td>
        <td style="text-align: right">404.92</td>
        <td style="text-align: right">42.81</td>
      </tr>
    </tbody>
  </table>

</div>

<p>I’ve included a couple of extra models in there for comparison, but the <code class="language-plaintext highlighter-rouge">gpt-oss</code> improvements were ~50% token generation, and 5x prompt evaluation.</p>]]></content><author><name>Josh Pencheon</name></author><category term="ai" /><category term="ruby" /><summary type="html"><![CDATA[I’ve been using Ollama for the past year or so to experiment with open-source large language models, and have most recently been trying out OpenAI’s gpt-oss 20 billion parameter model.]]></summary></entry><entry><title type="html">Understanding ZFS free space reporting</title><link href="http://josh.pencheon.dev/2025/09/21/understanding-zfs-free-space-reporting.html" rel="alternate" type="text/html" title="Understanding ZFS free space reporting" /><published>2025-09-21T00:00:00+00:00</published><updated>2025-09-21T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/09/21/understanding-zfs-free-space-reporting</id><content type="html" xml:base="http://josh.pencheon.dev/2025/09/21/understanding-zfs-free-space-reporting.html"><![CDATA[<p>Recently, I noted <code class="language-plaintext highlighter-rouge">btop</code> was showing some odd stats for a ZFS dataset; it was showing as 100% free, but with an unexpectedly small total capacity:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├─main-pool──────────────65,6─GiB─┤
│ IO% ........................... │
│ Used:  0% ■             128 KiB │
│ Free:100% ■■■■■■■■■■■■ 65,6 GiB │
├─────────────────────────────────┤
</code></pre></div></div>

<p>Checking with ZFS, this total capacity actually appeared to be the remaining space for the dataset:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ zfs list main-pool
NAME        USED  AVAIL  REFER  MOUNTPOINT
main-pool   365G  65.7G   128K  /main-pool
</code></pre></div></div>

<p>At first, I guessed there might be some sort of off-by-one error in btop’s reading of the ZFS statistics, so I had a poke around <a href="https://github.com/aristocratos/btop/blob/v1.4.4/src/linux/btop_collect.cpp#L2032-L2049">in the source</a>. While IO stats are read from the ZFS-provided interface <code class="language-plaintext highlighter-rouge">/proc/spl/kstat/zfs/main-pool/objset-*</code>, capacity stats are retrieved from a <code class="language-plaintext highlighter-rouge">statvfs</code> system call.</p>

<h2 id="reproducing-the-numbers">Reproducing the numbers</h2>

<p>So in fact, I was able to reproduce <code class="language-plaintext highlighter-rouge">btop</code>’s numbers, both via a python script:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;&gt;</span> <span class="kn">from</span> <span class="nn">os</span> <span class="kn">import</span> <span class="n">statvfs</span>
<span class="o">&gt;&gt;&gt;</span> <span class="n">stats</span> <span class="o">=</span> <span class="n">statvfs</span><span class="p">(</span><span class="s">"/main-pool"</span><span class="p">)</span>
<span class="o">&gt;&gt;&gt;</span> <span class="s">"{:.1f}GB"</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="n">stats</span><span class="p">.</span><span class="n">f_blocks</span> <span class="o">*</span> <span class="n">stats</span><span class="p">.</span><span class="n">f_bsize</span> <span class="o">/</span> <span class="p">(</span><span class="mi">2</span><span class="o">**</span><span class="mi">30</span><span class="p">))</span>
<span class="s">'65.7GB'</span>
</code></pre></div></div>

<p>…and via <code class="language-plaintext highlighter-rouge">df</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ df -h /main-pool
Filesystem      Size  Used Avail Use% Mounted on
main-pool        66G  128K   66G   1% /main-pool
</code></pre></div></div>

<h2 id="the-penny-drops">The penny drops</h2>

<p>I then realised that I had nested datasets that weren’t mounted, but were still consuming pool capacity. If I temporarily mounted them, things began to make sense:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>├─main-pool─────────────────────────────65,6─GiB─┤
│ IO% .......................................... │
│ Used:  0% ■                            128 KiB │
│ Free:100% ■■■■■■■■■■■■■■■■■■■■■■■■■■■ 65,6 GiB │
│                                                │
├─time-machine───────────────────────────418─GiB─┤
│ IO% .......................................... │
│ Used: 84% ■■■■■■■■■■■■■■■■■■■■■■       353 GiB │
│ Free: 16% ■■■■■■■■■■■■■■■■■■■■■■■■■■■ 65,6 GiB │
│                                                │
├────────────────────────────────────────────────┤
</code></pre></div></div>

<p>So it turns out ZFS exposes each mounted dataset individually, and reports the pool’s available space as free against each. Mystery solved!</p>]]></content><author><name>Josh Pencheon</name></author><category term="zfs" /><summary type="html"><![CDATA[Recently, I noted btop was showing some odd stats for a ZFS dataset; it was showing as 100% free, but with an unexpectedly small total capacity:]]></summary></entry><entry><title type="html">Managing AUR packages with a local repository</title><link href="http://josh.pencheon.dev/2025/05/03/managing-aur-packages-with-a-local-repository.html" rel="alternate" type="text/html" title="Managing AUR packages with a local repository" /><published>2025-05-03T00:00:00+00:00</published><updated>2025-05-03T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/05/03/managing-aur-packages-with-a-local-repository</id><content type="html" xml:base="http://josh.pencheon.dev/2025/05/03/managing-aur-packages-with-a-local-repository.html"><![CDATA[<p>The Arch User Repository (AUR) provides thousands of user-submitted packages, but each package in the AUR is just a git repository containing at minimum a <code class="language-plaintext highlighter-rouge">PKGBUILD</code> recipe. To install a package, you first check out the repository and review it, then use <code class="language-plaintext highlighter-rouge">makepkg</code> to install dependencies, download/verify the source material, and build it. You can then install the built package directly with <code class="language-plaintext highlighter-rouge">pacman -U</code>.</p>

<h2 id="the-option-of-an-aur-helper">The option of an AUR helper</h2>

<p>This process starts to get unwieldy once you’re using lots of inter-related packages from the AUR. At this point, people often start using an AUR helper like <code class="language-plaintext highlighter-rouge">yay</code>, which abstracts away much of the manual work that’s normally needed when using the AUR.</p>

<p>However, if you’re trying to be as selective as possible when using the AUR, an AUR helper might be overkill; a slightly more primitive solution would be sufficient.</p>

<h2 id="sticking-with-pacman">Sticking with pacman</h2>

<p>It’s possible to set up a local repository, and configure <code class="language-plaintext highlighter-rouge">pacman</code> to use it. I do this in <code class="language-plaintext highlighter-rouge">/srv/packages</code> (a location that’s readily accessible to pacman’s <code class="language-plaintext highlighter-rouge">alpm</code> download user).</p>

<h3 id="building-packages-into-the-local-repository">Building packages into the local repository</h3>

<p>The first step is to configure <code class="language-plaintext highlighter-rouge">makepkg</code> to set up the local repo as the destination for built packages:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-B</span> 1 ^PKGDEST /etc/makepkg.conf

<span class="c">#-- Destination: specify a fixed directory where all packages will be placed</span>
<span class="nv">PKGDEST</span><span class="o">=</span>/srv/packages
</code></pre></div></div>

<p>We then need to compile a repository database file that indexes all these packages:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /srv/packages
repo-add <span class="nt">--new</span> local-aur.db.tar.zst <span class="k">*</span>.pkg.tar.zst
</code></pre></div></div>

<p>This needs to be re-run each time a package is [re-]built with <code class="language-plaintext highlighter-rouge">makepkg</code>.</p>

<h3 id="configuring-pacman">Configuring pacman</h3>

<p>We can add the following to <code class="language-plaintext highlighter-rouge">/etc/pacman.conf</code> so it will synchronize packages from our local repo (as well as <code class="language-plaintext highlighter-rouge">core</code> and <code class="language-plaintext highlighter-rouge">extra</code>).</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[<span class="n">local</span>-<span class="n">aur</span>]
<span class="n">SigLevel</span> = <span class="n">Optional</span> <span class="n">TrustAll</span>
<span class="n">Server</span> = <span class="n">file</span>:///<span class="n">srv</span>/<span class="n">packages</span>
</code></pre></div></div>

<p>We can now search for and install packages from our local repository just like we would with any other! 🎉</p>

<h2 id="checking-for-aur-updates">Checking for AUR updates</h2>

<p>As AUR packages get updated, you have to manually update your git checkouts. I do this with a script to iterate over all my local versions, which I keep in <code class="language-plaintext highlighter-rouge">~/AUR</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fd <span class="nt">-td</span> <span class="nt">-d1</span> <span class="nt">-x</span> bash <span class="nt">-c</span> <span class="s2">"echo -en '{}</span><span class="se">\n</span><span class="s2">  ' &amp;&amp; git -C {} pull"</span> <span class="se">\;</span> <span class="nb">.</span> ~/AUR
</code></pre></div></div>

<p>I can then review any changes, and run <code class="language-plaintext highlighter-rouge">makepkg</code> / <code class="language-plaintext highlighter-rouge">repo-add</code> / <code class="language-plaintext highlighter-rouge">pacman -Syu</code> to install.</p>]]></content><author><name>Josh Pencheon</name></author><category term="arch" /><summary type="html"><![CDATA[The Arch User Repository (AUR) provides thousands of user-submitted packages, but each package in the AUR is just a git repository containing at minimum a PKGBUILD recipe. To install a package, you first check out the repository and review it, then use makepkg to install dependencies, download/verify the source material, and build it. You can then install the built package directly with pacman -U.]]></summary></entry><entry><title type="html">Shipping ZFS snapshots with Syncoid</title><link href="http://josh.pencheon.dev/2025/04/12/shipping-zfs-snapshots-with-syncoid.html" rel="alternate" type="text/html" title="Shipping ZFS snapshots with Syncoid" /><published>2025-04-12T00:00:00+00:00</published><updated>2025-04-12T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/04/12/shipping-zfs-snapshots-with-syncoid</id><content type="html" xml:base="http://josh.pencheon.dev/2025/04/12/shipping-zfs-snapshots-with-syncoid.html"><![CDATA[<p><a href="https://github.com/jimsalterjrs/sanoid">Sanoid</a> is a popular choice for maintaining a history of a ZFS dataset via snapshots. It gives us the ability to use ZFS <code class="language-plaintext highlighter-rouge">send</code>/<code class="language-plaintext highlighter-rouge">recv</code> to efficiently back up the dataset to a separate machine, transmitting just the deltas between snapshots. <a href="https://github.com/jimsalterjrs/sanoid#syncoid">Syncoid</a> is a tool to help with this, bundled as part of <code class="language-plaintext highlighter-rouge">sanoid</code>. If used with something like <a href="https://tailscale.com/">tailscale</a>, it can make offsite backups a breeze!</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>We’ll want a few ancillary optional packages installed (<code class="language-plaintext highlighter-rouge">pv</code>, <code class="language-plaintext highlighter-rouge">mbuffer</code>, <code class="language-plaintext highlighter-rouge">lzop</code>) to improve the experience. The main thing we’ll want to get configured is non-root users on the source (user: <code class="language-plaintext highlighter-rouge">syncoid-sender</code>) and destination (user: <code class="language-plaintext highlighter-rouge">syncoid-receiver</code>) machines with sufficient delegated ZFS permissions to transfer the snapshots.</p>

<h3 id="sending-permissions">Sending permissions</h3>

<p>We’ll allow the sending user to send data from the desired dataset(s), as well as to be able to place and release ZFS holds; this allows us to prevent snapshots that are being used by the process from being removed prematurely.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>zfs allow syncoid-sender hold,send,release main-pool/test-dataset
</code></pre></div></div>

<p><em>Note that <code class="language-plaintext highlighter-rouge">send</code> does permit already-decrypted data to be sent. At the original time of writing, it was not possible to grant only the use of <code class="language-plaintext highlighter-rouge">send --raw</code>. However <a href="https://github.com/openzfs/zfs/issues/13099">a proposal to add such a grant</a> has subsequently been accepted and released as part of ZFS 2.4.0, meaning <code class="language-plaintext highlighter-rouge">send:raw</code> can be used instead.</em></p>

<h3 id="receiving-permissions">Receiving permissions</h3>

<p>On the receiving end, we’ll want to configure a dataset in which to receive the backups:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>zfs create <span class="nt">-o</span> <span class="nb">readonly</span><span class="o">=</span>on main-pool/backup
</code></pre></div></div>

<p>…then allow our local user the ability to <code class="language-plaintext highlighter-rouge">zfs recv</code> into new datasets within it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>zfs allow syncoid-receiver create,mount,receive,hold,release main-pool/backup
</code></pre></div></div>

<h2 id="pushing-an-initial-backup">Pushing an initial backup</h2>

<p>To start, we’ll push the baseline of the dataset over SSH in order to establish a backup dataset on the receiving machine. Once SSH keys have been set up, we can run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>syncoid <span class="se">\</span>
  <span class="nt">--sendoptions</span><span class="o">=</span>raw <span class="se">\</span>
  <span class="nt">--no-privilege-elevation</span> <span class="se">\</span>
  <span class="nt">--no-sync-snap</span> <span class="se">\</span>
  <span class="nt">--no-rollback</span> <span class="se">\</span>
  <span class="nt">--use-hold</span> <span class="se">\</span>
  main-pool/test-dataset <span class="se">\</span>
  syncoid-receiver@receiving-machine:main-pool/backup/test-dataset
</code></pre></div></div>

<p>Breaking down these options:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--sendoptions=raw</code> sends still-encrypted data. We don’t need to load the decryption keys on the destination machine.</li>
  <li><code class="language-plaintext highlighter-rouge">--no-privilege-elevation</code> prevents the use of <code class="language-plaintext highlighter-rouge">sudo</code>, as we’ve granted the necessary delegated permissions at both ends.</li>
  <li><code class="language-plaintext highlighter-rouge">--no-sync-snap</code> avoids the creation of extra <code class="language-plaintext highlighter-rouge">syncoid</code>-specific snapshots; the existing snapshot from the preexisting <code class="language-plaintext highlighter-rouge">sanoid</code> policy will suffice.</li>
  <li><code class="language-plaintext highlighter-rouge">--no-rollback</code> stops the backup dataset from being rolled back if snapshots go missing from the original dataset.</li>
  <li><code class="language-plaintext highlighter-rouge">--use-hold</code> ensures that a ZFS hold is placed on the latest snapshots needed for deltas to be producible, preventing their removal (which would then require a entire re-sync).</li>
</ul>

<h2 id="verifying-the-data-has-arrived">Verifying the data has arrived</h2>

<p>If we want, we can temporarily load the decryption key and mount the backup dataset to explore it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>zfs mount main-pool/backup/test-dataset
<span class="c"># cannot mount 'main-pool/backup/test-dataset': encryption key not loaded</span>

<span class="nb">sudo </span>zfs load-key main-pool/backup/test-dataset
<span class="nb">sudo </span>zfs mount main-pool/backup/test-dataset

<span class="c"># [...] verify things are as expected</span>

<span class="nb">sudo </span>zfs unmount main-pool/backup/test-dataset
<span class="nb">sudo </span>zfs unload-key main-pool/backup/test-dataset
</code></pre></div></div>

<h2 id="pulling-subsequent-deltas">Pulling subsequent deltas</h2>

<p>We’ll pull data from the sending machine to the receiving machine as the <code class="language-plaintext highlighter-rouge">syncoid-receiver</code> user over SSH. With the appropriate SSH keys installed on the sending machine, we can incrementally pull any updates to our dataset with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>syncoid <span class="se">\</span>
  <span class="nt">--sendoptions</span><span class="o">=</span>raw <span class="se">\</span>
  <span class="nt">--no-privilege-elevation</span> <span class="se">\</span>
  <span class="nt">--no-sync-snap</span> <span class="se">\</span>
  <span class="nt">--no-rollback</span> <span class="se">\</span>
  <span class="nt">--use-hold</span> <span class="se">\</span>
  syncoid-sender@sending-machine:main-pool/test-dataset <span class="se">\</span>
  main-pool/backup/test-dataset
</code></pre></div></div>

<h2 id="pruning-snapshots">Pruning snapshots</h2>

<p>The snapshot history from the source dataset will be preserved in the backup too, but we’ll want to do similar housekeeping with <code class="language-plaintext highlighter-rouge">sanoid</code> to ensure they don’t build up too much. We can even choose to keep an eye on the age of the latest snapshots present as a way of validating that our backups are continuing to take place.</p>

<p>Sample backup server <code class="language-plaintext highlighter-rouge">sanoid</code> configuration:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /etc/sanoid/sanoid.conf
</span><span class="nn">[main-pool/backup/test-dataset]</span>
        <span class="py">frequently</span> <span class="p">=</span> <span class="s">0</span>
        <span class="py">hourly</span> <span class="p">=</span> <span class="s">24</span>
        <span class="py">daily</span> <span class="p">=</span> <span class="s">5</span>
        <span class="py">monthly</span> <span class="p">=</span> <span class="s">2</span>
        <span class="py">yearly</span> <span class="p">=</span> <span class="s">0</span>

        <span class="c"># Retain snapshots according to ^:
</span>        <span class="py">autoprune</span> <span class="p">=</span> <span class="s">yes</span>

        <span class="c"># Don't take any new snapshots:
</span>        <span class="py">autosnap</span> <span class="p">=</span> <span class="s">no</span>

        <span class="c"># If using nagios or similar, this has
</span>        <span class="c"># sanoid --monitor-snapshots get worried
</span>        <span class="c"># after a few days of missing dailies:
</span>        <span class="py">monitor</span> <span class="p">=</span> <span class="s">yes</span>
        <span class="py">daily_warn</span> <span class="p">=</span> <span class="s">48h</span>
        <span class="py">daily_crit</span> <span class="p">=</span> <span class="s">60h</span>
</code></pre></div></div>

<p>The final thing to do is to ensure that sanoid is run periodically to perform this maintenance:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>systemctl <span class="nb">enable</span> <span class="nt">--now</span> sanoid.timer
</code></pre></div></div>]]></content><author><name>Josh Pencheon</name></author><category term="zfs" /><summary type="html"><![CDATA[Sanoid is a popular choice for maintaining a history of a ZFS dataset via snapshots. It gives us the ability to use ZFS send/recv to efficiently back up the dataset to a separate machine, transmitting just the deltas between snapshots. Syncoid is a tool to help with this, bundled as part of sanoid. If used with something like tailscale, it can make offsite backups a breeze!]]></summary></entry><entry><title type="html">Installing ZFS with DKMS</title><link href="http://josh.pencheon.dev/2025/03/15/installing-zfs-with-dkms.html" rel="alternate" type="text/html" title="Installing ZFS with DKMS" /><published>2025-03-15T00:00:00+00:00</published><updated>2025-03-15T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/03/15/installing-zfs-with-dkms</id><content type="html" xml:base="http://josh.pencheon.dev/2025/03/15/installing-zfs-with-dkms.html"><![CDATA[<p>For licencing reasons, ZFS is not able to integrated into the mainline linux kernel. Some distributions (like Ubuntu) do package the modules into their kernels, whereas others (like Arch) exclude it from their official repositories. In this latter case, you then have two choices - enable a third-party repository that provides built binary modules for specific kernel versions, or use <a href="https://en.wikipedia.org/wiki/Dynamic_Kernel_Module_Support">DKMS</a> to build your own. We’ll be exploring the latter option.</p>

<h2 id="the-downside-of-dkms">The downside of DKMS</h2>

<p>It’s possible that a DKMS build may fail, or that the kernel being provided by Arch’s rolling release model gets ahead and is temporarily unsupported by ZFS. For this reason, we’ll install multiple kernels so we can (hopefully!) have one as a fallback should something go wrong.</p>

<h2 id="required-packages">Required packages</h2>

<p>We’ll use both the standard <code class="language-plaintext highlighter-rouge">linux</code> kernel package, as well as <code class="language-plaintext highlighter-rouge">linux-lts</code>. We’ll need to install the <code class="language-plaintext highlighter-rouge">*-headers</code> packages for both too, so we can build the ZFS modules against them.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Some of these will already be installed</span>
<span class="nb">sudo </span>pacman <span class="nt">--needed</span> <span class="nt">-S</span> linux linux-headers linux-lts linux-lts-headers dkms mkinitcpio git
</code></pre></div></div>

<h2 id="zfs-in-the-aur">ZFS in the AUR</h2>

<p>Packages for ZFS are available in the Arch User Repository (AUR), but these are just git repositories containing <code class="language-plaintext highlighter-rouge">PKGBUILD</code> recipes; you clone them and build yourself using the pacman-provided <code class="language-plaintext highlighter-rouge">makepkg</code>. You can layer additional tools like <code class="language-plaintext highlighter-rouge">yay</code> to automate more of the use of the AUR, but we’ll stick with the basics for now.</p>

<p>For ZFS, we’ll need two packages - the <code class="language-plaintext highlighter-rouge">zfs-dkms</code> kernel modules and the <code class="language-plaintext highlighter-rouge">zfs-utils</code> utilities.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://aur.archlinux.org/zfs-dkms.git
git clone https://aur.archlinux.org/zfs-utils.git
</code></pre></div></div>

<p>Within each checkout, we run <code class="language-plaintext highlighter-rouge">makepkg -si</code> to [s]ync any missing dependencies from the official repositories, download and build, then temporarily elevate to <code class="language-plaintext highlighter-rouge">root</code> in order to [i]nstall.</p>

<h2 id="pacman-hooks">Pacman hooks</h2>

<p>At this point, a number of pacman hooks (in <code class="language-plaintext highlighter-rouge">/usr/share/libalpm/hooks</code>) will come in to play - the <code class="language-plaintext highlighter-rouge">dkms-install</code> hook will detect that the <code class="language-plaintext highlighter-rouge">zfs-dkms</code> package has placed kernel modules into <code class="language-plaintext highlighter-rouge">/usr/lib/modules/</code>, and will therefore build them for each of the installed kernels.</p>

<p>A following <code class="language-plaintext highlighter-rouge">mkinitcpio-install</code> pacman hook will then create a new initramfs for each kernel, ensuring the new modules are available during early stage boot. In fact, mkinitcpio will by default also create a second <code class="language-plaintext highlighter-rouge">-fallback.img</code> initramfs for each kernel; this skips the <code class="language-plaintext highlighter-rouge">autodetect</code> mkinitcpio hook that would normally prune modules deemed unnecessary for the hardware, leading to a larger but hopefully more-compatible environment.</p>

<h2 id="bootloader">Bootloader</h2>

<p>In order for our LTS kernel to give us a chance to easily recover into a bootable system should we have any issues with the latest <code class="language-plaintext highlighter-rouge">linux</code> package, we’ll need to ensure it’s selectable in the bootloader. I’m using <code class="language-plaintext highlighter-rouge">systemd-boot</code> with the EFI System Partition mounted at <code class="language-plaintext highlighter-rouge">/boot</code>, so we need to create entries as <code class="language-plaintext highlighter-rouge">/boot/loader/entries/*.conf</code>. As an example, here’s the config for booting with the LTS kernel and its corresponding pared-down initramfs:</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /boot/loader/entries/linux-lts.conf
</span><span class="n">title</span>   <span class="n">Arch</span> <span class="n">Linux</span> (<span class="n">LTS</span> <span class="n">kernel</span>)
<span class="n">linux</span>   /<span class="n">vmlinuz</span>-<span class="n">linux</span>-<span class="n">lts</span>
<span class="n">initrd</span>  /<span class="n">initramfs</span>-<span class="n">linux</span>-<span class="n">lts</span>.<span class="n">img</span>
<span class="n">options</span> <span class="n">root</span>=<span class="n">PARTUUID</span>=<span class="n">xxxx</span>-<span class="n">xxxx</span>-<span class="n">xxxx</span> <span class="n">rw</span>
</code></pre></div></div>

<p>We can then use <code class="language-plaintext highlighter-rouge">bootctl list</code> to check they’re all showing up, and expect to see entries like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>         type: Boot Loader Specification Type #1 (.conf)
        title: Arch Linux (LTS kernel)
           id: arch-lts.conf
       source: /boot//loader/entries/arch-lts.conf (on the EFI System Partition)
        linux: /boot//vmlinuz-linux-lts
       initrd: /boot//initramfs-linux-lts.img
      options: root=PARTUUID=xxxx-xxxx-xxxx rw
</code></pre></div></div>

<p>At this point, we should be able to reboot into our ZFS-enabled kernel!</p>]]></content><author><name>Josh Pencheon</name></author><category term="arch" /><category term="zfs" /><summary type="html"><![CDATA[For licencing reasons, ZFS is not able to integrated into the mainline linux kernel. Some distributions (like Ubuntu) do package the modules into their kernels, whereas others (like Arch) exclude it from their official repositories. In this latter case, you then have two choices - enable a third-party repository that provides built binary modules for specific kernel versions, or use DKMS to build your own. We’ll be exploring the latter option.]]></summary></entry><entry><title type="html">Fixing GT210 black screen with Nouveau</title><link href="http://josh.pencheon.dev/2025/03/09/fixing-gt210-black-screen-with-nouveau.html" rel="alternate" type="text/html" title="Fixing GT210 black screen with Nouveau" /><published>2025-03-09T00:00:00+00:00</published><updated>2025-03-09T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/03/09/fixing-gt210-black-screen-with-nouveau</id><content type="html" xml:base="http://josh.pencheon.dev/2025/03/09/fixing-gt210-black-screen-with-nouveau.html"><![CDATA[<p>I’ve got an ancient Nvidia GT210, a GPU that was old even when it was new. Recently, I’ve been having issues with kernel and driver support. Nvidia stopped supporting this card a long time ago (<a href="https://www.nvidia.com/en-us/geforce/drivers/results/113161/">340.x</a> being the last driver series that supported it), so I’ve always used the reverse-engineered nouveau drivers instead.</p>

<p>In trying to update to Ubuntu 24.10, I was getting just a black screen (sometimes with a cursor) whenever trying to start <code class="language-plaintext highlighter-rouge">gdm</code>, followed by hangs / long pauses before a different virtual terminal could be switched to. A pre-release build of 25.04 exhibited the same symptoms, as did a very pared down Arch + GNOME install. No end of kernel flags were tried without success, and with no smoking gun in any logs I could find I became resigned to the idea that my GT210 had finally become obsolete.</p>

<p>However, I stumbled across a suggestion that the issue might be with Mutter, and found this minimal addition to <code class="language-plaintext highlighter-rouge">/etc/environment</code> fixed everything:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># cat /etc/environment</span>
<span class="c"># Hail Mary to try and get GT210 working with GNOME:</span>
<span class="nv">GSK_RENDERER</span><span class="o">=</span>gl
<span class="nv">MUTTER_DEBUG_DISABLE_TRIPLE_BUFFERING</span><span class="o">=</span>1
<span class="nv">MUTTER_DEBUG_USE_KMS_MODIFIERS</span><span class="o">=</span>0
</code></pre></div></div>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># pacman -Q linux mesa gdm mutter</span>
linux 6.13.5.arch1-1
mesa 1:24.3.4-1
gdm 47.0-2
mutter 47.6-1
</code></pre></div></div>

<p>The GT210 and its 1080p maximum resolution lives on, hurray!</p>]]></content><author><name>Josh Pencheon</name></author><category term="nvidia" /><summary type="html"><![CDATA[I’ve got an ancient Nvidia GT210, a GPU that was old even when it was new. Recently, I’ve been having issues with kernel and driver support. Nvidia stopped supporting this card a long time ago (340.x being the last driver series that supported it), so I’ve always used the reverse-engineered nouveau drivers instead.]]></summary></entry><entry><title type="html">Mounting ZFS encrypted datasets on boot</title><link href="http://josh.pencheon.dev/2025/03/08/mounting-zfs-encrypted-datasets-on-boot.html" rel="alternate" type="text/html" title="Mounting ZFS encrypted datasets on boot" /><published>2025-03-08T00:00:00+00:00</published><updated>2025-03-08T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/03/08/mounting-zfs-encrypted-datasets-on-boot</id><content type="html" xml:base="http://josh.pencheon.dev/2025/03/08/mounting-zfs-encrypted-datasets-on-boot.html"><![CDATA[<p>I previously wrote about <a href="/2025/02/23/retrospectively-enabling-zfs-encryption.html">enabling encryption for a ZFS dataset</a>, but didn’t cover how to go about mounting such a dataset on boot. If using <code class="language-plaintext highlighter-rouge">keylocation=prompt</code> an interactive solution will be required, but as I’m using a keyfile held outside of ZFS an automatic solution is possible.</p>

<h2 id="overview-of-the-problem">Overview of the problem</h2>

<p>Conceptually, there are three things that need to happen:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># The system needs to learn of the key's location:</span>
zfs get keylocation main-pool/test-dataset

<span class="c"># Once available, this key needs to be loaded in to ZFS:</span>
zfs load-key main-pool/test-dataset

<span class="c"># Once loaded, the requiring dataset can be mounted:</span>
zfs mount main-pool/test-dataset
</code></pre></div></div>

<p>It turns out that this is a solved problem, and a solution is achievable using a combination of <a href="https://openzfs.github.io/openzfs-docs/man/v2.2/8/zed.8.html">the ZFS event daemon</a> and a <a href="https://www.freedesktop.org/software/systemd/man/latest/systemd.generator.html">systemd generator</a>.</p>

<h2 id="systemd-to-the-rescue">Systemd to the rescue</h2>

<p>OpenZFS <a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-mount-generator.8.html">bundles a system generator</a> that gets installed at <code class="language-plaintext highlighter-rouge">/usr/lib/systemd/system-generators/zfs-mount-generator</code>. This runs very early in the boot process (before units are loaded) and has an opportunity to generate further units dynamically.</p>

<p>It is too early in the boot process to interrogate ZFS directly for mounts, so instead the mount generator is driven by a cached version of <code class="language-plaintext highlighter-rouge">zfs list</code> output. This cache is maintained by the ZEDLET <code class="language-plaintext highlighter-rouge">history_event-zfs-list-cacher</code>, which is in turn invoked by the ZFS Event Daemon <code class="language-plaintext highlighter-rouge">zed.service</code> when datasets are modified. From <a href="https://github.com/openzfs/zfs/blob/master/cmd/zed/zed.d/history_event-zfs-list-cacher.sh.in">the source</a> of this ZEDLET, we can see it will keep a cached version of ZFS datasets’ info written to <code class="language-plaintext highlighter-rouge">/etc/zfs/zfs-list.cache/&lt;POOL_NAME&gt;</code>, as long as the location exists.</p>

<p>This location doesn’t exist by default, but we can ensure it does:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo mkdir</span> <span class="nt">-p</span> /etc/zfs/zfs-list.cache
<span class="nb">sudo touch</span> /etc/zfs/zfs-list.cache/main-pool
</code></pre></div></div>

<p>Once we’ve triggered some cacheable activity (e.g. tweaking a dataset property), this cache should be written to by the ZEDLET.</p>

<p>With a populated cache in place, all we should have to do is reboot! Once the system comes back up, we should find new systemd units that have been generated as a result; a mount unit bound to a key loading service.</p>

<h3 id="checking-out-the-dynamic-units">Checking out the dynamic units</h3>

<p>If we look at the new mount unit for our encrypted dataset (with <code class="language-plaintext highlighter-rouge">systemctl cat srv-test\\x2ddataset.mount</code>) we can see that it now binds to a service for loading the dataset’s key:</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /run/systemd/generator/srv-test\x2ddataset.mount
# Automatically generated by zfs-mount-generator
</span>
<span class="nn">[Unit]</span>
<span class="py">SourcePath</span><span class="p">=</span><span class="s">/etc/zfs/zfs-list.cache/main-pool</span>
<span class="py">Documentation</span><span class="p">=</span><span class="s">man:zfs-mount-generator(8)</span>
<span class="py">Before</span><span class="p">=</span><span class="s">zfs-mount.service local-fs.target</span>
<span class="py">After</span><span class="p">=</span> <span class="s">zfs-load-key-main</span><span class="se">\x</span><span class="s">2dpool-test</span><span class="se">\x</span><span class="s">2ddataset.service</span>
<span class="py">Wants</span><span class="p">=</span>
<span class="py">BindsTo</span><span class="p">=</span><span class="s">zfs-load-key-main</span><span class="se">\x</span><span class="s">2dpool-test</span><span class="se">\x</span><span class="s">2ddataset.service</span>

<span class="nn">[Mount]</span>
<span class="py">Where</span><span class="p">=</span><span class="s">/srv/test-dataset</span>
<span class="py">What</span><span class="p">=</span><span class="s">main-pool/test-dataset</span>
<span class="py">Type</span><span class="p">=</span><span class="s">zfs</span>
<span class="py">Options</span><span class="p">=</span><span class="s">defaults,atime,relatime,dev,exec,rw,suid,nomand,zfsutil</span>
</code></pre></div></div>

<p>Taking a look at <em>that</em> service’s definition, we can see it waits for the <code class="language-plaintext highlighter-rouge">keylocation</code> to be mounted, then runs <code class="language-plaintext highlighter-rouge">zfs load-key</code> for our dataset if it still needs it.</p>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># /run/systemd/generator/zfs-load-key-main\x2dpool-test\x2ddataset.service
# Automatically generated by zfs-mount-generator
</span>
<span class="nn">[Unit]</span>
<span class="py">Description</span><span class="p">=</span><span class="s">Load ZFS key for main-pool/test-dataset</span>
<span class="py">SourcePath</span><span class="p">=</span><span class="s">/etc/zfs/zfs-list.cache/main-pool</span>
<span class="py">Documentation</span><span class="p">=</span><span class="s">man:zfs-mount-generator(8)</span>
<span class="py">DefaultDependencies</span><span class="p">=</span><span class="s">no</span>
<span class="py">Wants</span><span class="p">=</span>
<span class="py">After</span><span class="p">=</span>
<span class="py">RequiresMountsFor</span><span class="p">=</span><span class="s">'/root/main-pool-test-dataset.key'</span>

<span class="nn">[Service]</span>
<span class="py">Type</span><span class="p">=</span><span class="s">oneshot</span>
<span class="py">RemainAfterExit</span><span class="p">=</span><span class="s">yes</span>
<span class="c"># This avoids a dependency loop involving systemd-journald.socket if this
# dataset is a parent of the root filesystem.
</span><span class="py">StandardOutput</span><span class="p">=</span><span class="s">null</span>
<span class="py">StandardError</span><span class="p">=</span><span class="s">null</span>
<span class="py">ExecStart</span><span class="p">=</span><span class="s">/bin/sh -c 'set -eu;keystatus="$$(/sbin/zfs get -H -o value keystatus "main-pool/test-dataset")";[ "$$keystatus" = "unavailable" ] || exit 0;/sbin/zfs load-key "main-pool/test-dataset"'</span>
<span class="py">ExecStop</span><span class="p">=</span><span class="s">/bin/sh -c 'set -eu;keystatus="$$(/sbin/zfs get -H -o value keystatus "main-pool/test-dataset")";[ "$$keystatus" = "available" ] || exit 0;/sbin/zfs unload-key "main-pool/test-dataset"'</span>
</code></pre></div></div>

<p>All being well, these units will have done their job and the encrypted dataset should be mounted already:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># findmnt --mountpoint /srv/test-dataset</span>
TARGET            SOURCE                 FSTYPE OPTIONS
/srv/test-dataset main-pool/test-dataset zfs    rw,relatime,xattr,noacl,casesensitive
</code></pre></div></div>

<p>This in turn should have triggered any <a href="/2025/02/24/binding-services-together-with-systemd.html">dependent downstream services (like Samba)</a> to start too.</p>]]></content><author><name>Josh Pencheon</name></author><category term="systemd" /><category term="zfs" /><summary type="html"><![CDATA[I previously wrote about enabling encryption for a ZFS dataset, but didn’t cover how to go about mounting such a dataset on boot. If using keylocation=prompt an interactive solution will be required, but as I’m using a keyfile held outside of ZFS an automatic solution is possible.]]></summary></entry><entry><title type="html">Binding services together with systemd</title><link href="http://josh.pencheon.dev/2025/02/24/binding-services-together-with-systemd.html" rel="alternate" type="text/html" title="Binding services together with systemd" /><published>2025-02-24T00:00:00+00:00</published><updated>2025-02-24T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/02/24/binding-services-together-with-systemd</id><content type="html" xml:base="http://josh.pencheon.dev/2025/02/24/binding-services-together-with-systemd.html"><![CDATA[<p>I’ve got an SMB share that’s serving files stored in ZFS. I don’t want the samba server to start until the ZFS dataset is mounted, and nor do I want the server to continue running should the dataset get unmounted; this might risk data being written without the desired ZFS encryption in place.</p>

<p>Luckily, these scenarios can be prevented by associating the SMB systemd <code class="language-plaintext highlighter-rouge">.service</code> unit with the <code class="language-plaintext highlighter-rouge">.mount</code> unit that’s created as a result of the ZFS setup.</p>

<p>We can use <code class="language-plaintext highlighter-rouge">sudo systemctl edit smbd.service</code> and then specify via an override that:</p>

<ul>
  <li>the SMB daemon should not be started until the <code class="language-plaintext highlighter-rouge">time-machine</code> dataset is mounted (using <code class="language-plaintext highlighter-rouge">After=</code>), and</li>
  <li>should the dataset be unmounted, the daemon should also be stopped (using <code class="language-plaintext highlighter-rouge">BindsTo=</code>)</li>
</ul>

<div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Unit]</span>
<span class="c"># Serve only when the ZFS mount is available
</span><span class="py">BindsTo</span><span class="p">=</span><span class="s">srv-time</span><span class="se">\x</span><span class="s">2dmachine.mount</span>
<span class="py">After</span><span class="p">=</span><span class="s">srv-time</span><span class="se">\x</span><span class="s">2dmachine.mount</span>
</code></pre></div></div>

<h2 id="auto-restart">Auto-restart</h2>

<p>If we wanted to, we could go a step further and use <code class="language-plaintext highlighter-rouge">sudo systemctl edit srv-time\\x2dmachine.mount</code> to add a <code class="language-plaintext highlighter-rouge">Wants=smbd.service</code> declaration back in the other direction; this would attempt to automatically start the SMB daemon should the <code class="language-plaintext highlighter-rouge">time-machine</code> dataset get (re)mounted.</p>]]></content><author><name>Josh Pencheon</name></author><category term="systemd" /><category term="zfs" /><summary type="html"><![CDATA[I’ve got an SMB share that’s serving files stored in ZFS. I don’t want the samba server to start until the ZFS dataset is mounted, and nor do I want the server to continue running should the dataset get unmounted; this might risk data being written without the desired ZFS encryption in place.]]></summary></entry><entry><title type="html">Retrospectively enabling ZFS encryption</title><link href="http://josh.pencheon.dev/2025/02/23/retrospectively-enabling-zfs-encryption.html" rel="alternate" type="text/html" title="Retrospectively enabling ZFS encryption" /><published>2025-02-23T00:00:00+00:00</published><updated>2025-02-23T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/02/23/retrospectively-enabling-zfs-encryption</id><content type="html" xml:base="http://josh.pencheon.dev/2025/02/23/retrospectively-enabling-zfs-encryption.html"><![CDATA[<p>Suppose we have an existing ZFS dataset that was created without encryption, and we’d now like to encrypt it. This is something that can’t be changed once a dataset has been created, so we have to get a little creative.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Here's our existing dataset in the "main-pool" pool, without encryption:</span>
<span class="nb">sudo </span>zfs create main-pool/test-dataset
</code></pre></div></div>

<h2 id="establishing-a-baseline">Establishing a baseline</h2>

<p>So we’re working on something consistent, we can either unmount the dataset, or take a snapshot. We’ll do both; the former preventing further changes, and the latter so we can use the full <code class="language-plaintext highlighter-rouge">--replicate</code> option later on:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>zfs unmount main-pool/test-dataset
<span class="nb">sudo </span>zfs snapshot main-pool/test-dataset@encryption-time
</code></pre></div></div>

<h2 id="key-management">Key management</h2>

<p>We then need a key, which we’ll choose to store on the host filesystem (rather than require it to be provided interactively when mounting):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>openssl rand <span class="nt">-hex</span> <span class="nt">-out</span> /root/main-pool-test-dataset.key 32
</code></pre></div></div>

<p>It may be prudent to keep a copy of this somewhere safe, too.</p>

<h2 id="replicating-the-dataset">Replicating the dataset</h2>

<p>We’ll now use ZFS’s <code class="language-plaintext highlighter-rouge">send</code>/<code class="language-plaintext highlighter-rouge">receive</code> to create a new dataset with encryption enabled:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>zfs send <span class="nt">--replicate</span> <span class="nt">--verbose</span> <span class="se">\</span>
  main-pool/test-dataset@encryption-time <span class="se">\</span>
  | <span class="nb">sudo </span>zfs receive <span class="se">\</span>
  <span class="nt">-s</span> <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">encryption</span><span class="o">=</span>on <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">keylocation</span><span class="o">=</span>file:///root/main-pool-test-dataset.key <span class="se">\</span>
  <span class="nt">-o</span> <span class="nv">keyformat</span><span class="o">=</span>hex <span class="se">\</span>
  main-pool/encrypted-test-dataset
</code></pre></div></div>

<h3 id="dealing-with-interruptions">Dealing with interruptions</h3>

<p>Depending on the size of the source dataset and capabilities of the hardware, it may may take a long time to create the encrypted version of the dataset. This process may get interrupted; if so, the <code class="language-plaintext highlighter-rouge">-s</code> flag given to <code class="language-plaintext highlighter-rouge">zfs receive</code> will have caused a “progress” token to be saved against the new dataset. We can extract that, and resume the <code class="language-plaintext highlighter-rouge">zfs send</code> from that point:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># extract the resume token from the target dataset:</span>
<span class="nv">token</span><span class="o">=</span><span class="si">$(</span>zfs get <span class="nt">-o</span> value <span class="nt">-H</span> receive_resume_token main-pool/encrypted-test-dataset<span class="si">)</span>

<span class="c"># restart sending the source dataset from that point:</span>
<span class="nb">sudo </span>zfs send <span class="nt">--verbose</span> <span class="nt">-t</span> <span class="nv">$token</span> <span class="se">\</span>
  | <span class="nb">sudo </span>zfs receive <span class="nt">-s</span> main-pool/encrypted-test-dataset
</code></pre></div></div>

<h3 id="swapping-datasets">Swapping datasets</h3>

<p>Once happy, we can rename the new encrypted version in place of the original:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>zfs rename main-pool/test-dataset main-pool/unencrypted-test-dataset
<span class="nb">sudo </span>zfs rename main-pool/encrypted-test-dataset main-pool/test-dataset
</code></pre></div></div>

<h2 id="reclaiming-space">Reclaiming space</h2>

<p>If desired, we can then destroy the unencrypted version of the dataset to release the capacity back to the pool:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>zfs destroy <span class="nt">-r</span> main-pool/unencrypted-test-dataset
</code></pre></div></div>

<h3 id="secure-clean-up">Secure clean up</h3>

<p>In order make this a secure erase, we can either:</p>

<ul>
  <li>run <code class="language-plaintext highlighter-rouge">sudo zpool initialize -w main-pool</code> to zero over the freed space, or</li>
  <li>issue a secure TRIM erase instruction to the physical devices with <code class="language-plaintext highlighter-rouge">sudo zpool trim --secure -w main-pool</code> (hardware support allowing).</li>
</ul>

<h2 id="further-reading">Further reading</h2>

<p>I’ve written separately on <a href="/2025/03/08/mounting-zfs-encrypted-datasets-on-boot.html">automatically mounting encrypted dataset on boot</a>, which is possible here due to the use of a file as the dataset’s <code class="language-plaintext highlighter-rouge">keylocation</code>.</p>]]></content><author><name>Josh Pencheon</name></author><category term="zfs" /><summary type="html"><![CDATA[Suppose we have an existing ZFS dataset that was created without encryption, and we’d now like to encrypt it. This is something that can’t be changed once a dataset has been created, so we have to get a little creative.]]></summary></entry><entry><title type="html">Booting Raspberry Pi with iSCSI</title><link href="http://josh.pencheon.dev/2025/02/16/booting-raspberry-pi-with-iscsi.html" rel="alternate" type="text/html" title="Booting Raspberry Pi with iSCSI" /><published>2025-02-16T00:00:00+00:00</published><updated>2025-02-16T00:00:00+00:00</updated><id>http://josh.pencheon.dev/2025/02/16/booting-raspberry-pi-with-iscsi</id><content type="html" xml:base="http://josh.pencheon.dev/2025/02/16/booting-raspberry-pi-with-iscsi.html"><![CDATA[<p>In order to get a Raspberry Pi booting without any physically attached storage, we need to consider a number of different phases:</p>

<ul>
  <li>Where does the bootloader (RPi firmware) get a boot image / kernel from?</li>
  <li>How is that initial boot environment delivered to the RPi?</li>
  <li>How does the rest of the root filesystem become available?</li>
  <li>How can the booting device update its own kernel?</li>
</ul>

<p>Let’s address those one at a time.</p>

<h2 id="enabling-netboot">Enabling netboot</h2>

<p>This is something <a href="/2023/12/17/enabling-netboot-on-raspberry-pi.html">I’ve covered before</a>, but in summary we have to include and prioritise a <code class="language-plaintext highlighter-rouge">BOOT_ORDER</code> value of <code class="language-plaintext highlighter-rouge">2</code> in the config stored in the RPi’s EEPROM, which will instruct it to try and source a kernel over the network via TFTP (Trivial File Transfer Protocol). While updating the EEPROM, we’ll also set <code class="language-plaintext highlighter-rouge">TFTP_PREFIX=2</code> - more on that later.</p>

<p>For other hardware, you may find configuration in your BIOS that allows similar.</p>

<h2 id="advertising-a-tftp-server">Advertising a TFTP server</h2>

<p>While it is possible to hard-code in the EEPROM config where the bootloader should go to find the TFTP server, I prefer to advertise this as part of <a href="https://www.rfc-editor.org/rfc/rfc2132.html#section-9">the DHCP offer</a> - but watch out for potential issues if you start doing this for your whole network!</p>

<p>I use ISC’s DHCP server on the server that is also acting as gateway for the network, and will additionally be taking on the role of TFTP server (and iSCSI server). Therefore, to the subnet configuration in <code class="language-plaintext highlighter-rouge">/etc/dhcp/dhcpd.conf</code> it is a simple matter of adding:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  range 192.168.20.10 192.168.20.200;
<span class="gi">+ option tftp-server-name "192.168.20.1";
</span>  option routers 192.168.20.1;
</code></pre></div></div>

<p>It’s worth noting that DHCP also supports advertising a “next server” for clients to contact, but the RPi firmware doesn’t support that, so I stuck with the BOOTP extension option 66.</p>

<p>We can then test this using one of <code class="language-plaintext highlighter-rouge">nmap</code>’s built-in scripts:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># sudo nmap --script broadcast-dhcp-discover
|   Response 1 of 1:
|     IP Offered: 192.168.20.26
|     DHCP Message Type: DHCPOFFER
|     Server Identifier: 192.168.20.1
|     [ ... ]
|_    TFTP Server Name: 192.168.20.1
</code></pre></div></div>
<h2 id="hosting-a-tftp-server">Hosting a TFTP server</h2>

<p>Now the RPi will know where to look for its boot image, we now need to provision something for it to find! A TFTP server can be installed with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt-get <span class="nb">install </span>tftpd-hpa
</code></pre></div></div>

<p>It is configured in <code class="language-plaintext highlighter-rouge">/etc/default/tftpd-hpa</code>; the defaults may be fine for you, but I configured the following starting settings:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TFTP_DIRECTORY="/srv/tftp"
TFTP_ADDRESS="192.168.20.1:69"
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">TFTP_PREFIX=2</code> we set earlier in the RPi’s EEPROM will mean that it requests files it needs prefixed with its MAC address (easy to find) rather than its serial number (less easy to find).</p>

<h3 id="extracting-the-boot-partition">Extracting the boot partition</h3>

<p>I’ll be using ZFS to store both the block device to be served over iSCSI as well as the boot files to be served over TFTP. Setting up individual datasets will allow for snapshotting and rollback.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Set up a dataset for the client with MAC address xx-xx-xx-xx-xx-xx:</span>
<span class="nv">CLIENT_DATASET</span><span class="o">=</span><span class="s2">"main-pool/netboot/xx-xx-xx-xx-xx-xx"</span>
<span class="nb">sudo </span>zfs create <span class="nt">-p</span> <span class="nv">$CLIENT_DATASET</span>
<span class="nb">sudo </span>zfs <span class="nb">set </span><span class="nv">mountpoint</span><span class="o">=</span>none main-pool/netboot

<span class="c"># Create a child zvol block device for iSCSI at '/vol':</span>
<span class="nb">sudo </span>zfs create <span class="nt">-V</span> 64G <span class="nv">$CLIENT_DATASET</span>/vol

<span class="c"># Fill it with a disk image:</span>
<span class="nb">sudo dd</span> <span class="se">\</span>
  <span class="k">if</span><span class="o">=</span>ubuntu-24.04.1-preinstalled-server-arm64+raspi.img <span class="se">\</span>
  <span class="nv">of</span><span class="o">=</span>/dev/zvol/<span class="nv">$CLIENT_DATASET</span>/vol <span class="se">\</span>
  <span class="nv">status</span><span class="o">=</span>progress

<span class="c"># Create a child dataset for TFTP at '/tftp':</span>
<span class="nb">sudo </span>zfs create <span class="nt">-o</span> <span class="nv">mountpoint</span><span class="o">=</span>/srv/tftp/xx-xx-xx-xx-xx-xx <span class="nv">$CLIENT_DATASET</span>/tftp

<span class="c"># Scan for partitions in the new block device:</span>
<span class="nb">sudo </span>fdisk <span class="nt">-l</span> /dev/zvol/<span class="nv">$CLIENT_DATASET</span>/vol

<span class="c"># Temporarily mount the first (boot) partition, and copy the contents to the TFTP directory:</span>
<span class="nb">sudo </span>mount /dev/zvol/<span class="nv">$CLIENT_DATASET</span>/vol-part1 /mnt
<span class="nb">sudo cp</span> <span class="nt">-r</span> /mnt/<span class="k">*</span> /srv/tftp/xx-xx-xx-xx-xx-xx/
<span class="nb">sudo </span>umount /mnt

<span class="c"># Finally, recursively snapshot this pristine state:</span>
<span class="nb">sudo </span>zfs snapshot <span class="nt">-r</span> <span class="nv">$CLIENT_DATASET</span>@pristine-24.04
</code></pre></div></div>

<h3 id="verifying-it-works">Verifying it works</h3>

<p>To observe things working, we can monitor DHCP logs and sniff for traffic on <code class="language-plaintext highlighter-rouge">:69</code> (in my case, shown here on the <code class="language-plaintext highlighter-rouge">vlan20</code> interface) when a client RPi tries to netboot:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># a DHCPREQUEST should arrive as per normal:</span>
journalctl <span class="nt">-u</span> isc-dhcp-server.service <span class="nt">-b</span> <span class="nt">-f</span>

<span class="c"># then, boot files should be requested via TFTP:</span>
<span class="nb">sudo </span>tcpdump <span class="nt">-vv</span> <span class="nt">-i</span> vlan20 port 69
</code></pre></div></div>

<h2 id="fetching-the-root-filesystem">Fetching the root filesystem</h2>

<p>At this point, our client RPi should be able to get a kernel started, but then panic as it won’t be able to find a root filesystem. For Ubuntu 24.04, the kernel <code class="language-plaintext highlighter-rouge">cmdline.txt</code> that we will have delivered over TFTP will have instructed it to look for a filesystem labelled as <code class="language-plaintext highlighter-rouge">writeable</code> to use as the root:</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code>... <span class="n">root</span>=<span class="n">LABEL</span>=<span class="n">writable</span> <span class="n">rootfstype</span>=<span class="n">ext4</span> <span class="n">rootwait</span> ...
</code></pre></div></div>

<p>This can still work, but we’ll need the kernel to act as an iSCSI initiator and fetch the block device over the network first before the relevant partition can be found. We can do that by editing <code class="language-plaintext highlighter-rouge">cmdline.txt</code> to include the following before <code class="language-plaintext highlighter-rouge">root=...</code> (again, substituting in the client’s MAC address):</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">iscsi_initiator</span>=<span class="n">iqn</span>.<span class="m">2025</span>-<span class="m">02</span>.<span class="n">dev</span>.<span class="n">pencheon</span>:<span class="n">cluster</span>:<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span> <span class="n">iscsi_target_name</span>=<span class="n">iqn</span>.<span class="m">2025</span>-<span class="m">02</span>.<span class="n">dev</span>.<span class="n">pencheon</span>:<span class="n">root</span>:<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span> <span class="n">iscsi_target_ip</span>=<span class="m">192</span>.<span class="m">168</span>.<span class="m">20</span>.<span class="m">1</span>
</code></pre></div></div>

<p>This will look for a iSCSI target called <code class="language-plaintext highlighter-rouge">iqn.2025-02.dev.pencheon:root:xx-xx-xx-xx-xx-xx</code> on the <code class="language-plaintext highlighter-rouge">192.168.20.1</code> host, self-identifying as <code class="language-plaintext highlighter-rouge">iqn.2025-02.dev.pencheon:cluster:xx-xx-xx-xx-xx-xx</code>.</p>

<p>In order to <em>serve</em> this, we’ll need to <a href="/2025/01/25/setting-up-isci-targets-on-ubuntu.html">set up the iSCSI target</a>, with the following configuration in <code class="language-plaintext highlighter-rouge">/etc/tgt/conf.d/xx-xx-xx-xx-xx-xx.conf</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;target iqn.2025-02.dev.pencheon:root:xx-xx-xx-xx-xx-xx&gt;
	initiator-name iqn.2025-02.dev.pencheon:cluster:xx-xx-xx-xx-xx-xx
	backing-store /dev/zvol/main-pool/netboot/xx-xx-xx-xx-xx-xx/vol
&lt;/target&gt;
</code></pre></div></div>

<h3 id="verifying-it-works-1">Verifying it works</h3>

<p>All being well, the client should now fetch the initial boot image over TFTP, start the kernel, attach the block device over iSCSI, and continue booting:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[   13.451314] Loading iSCSI transport class v2.0-870.
[   13.462700] iscsi: registered transport (tcp)
[   14.474254] scsi host0: iSCSI Initiator over TCP/IP
[   14.483862] scsi 0:0:0:0: RAID              IET      Controller       0001 PQ: 0 ANSI: 5
[   14.495861] scsi 0:0:0:0: Attached scsi generic sg0 type 12
[   14.502170] scsi 0:0:0:1: Direct-Access     IET      VIRTUAL-DISK     0001 PQ: 0 ANSI: 5
[   14.514185] sd 0:0:0:1: Attached scsi generic sg1 type 0
[   14.514307] sd 0:0:0:1: Power-on or device reset occurred
[   14.525439] sd 0:0:0:1: [sda] 134217728 512-byte logical blocks: (68.7 GB/64.0 GiB)
[   14.533353] sd 0:0:0:1: [sda] Write Protect is off
[   14.538571] sd 0:0:0:1: [sda] Write cache: enabled, read cache: enabled, supports DPO and FUA
[   14.550184]  sda: sda1 sda2
[   14.553106] sd 0:0:0:1: [sda] Attached SCSI disk
</code></pre></div></div>

<p>Once logged in, we should see the two partitions on this device, labelled and mounted:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lsblk -o name,size,mountpoints,label
NAME    SIZE MOUNTPOINTS       LABEL
loop0  33.7M /snap/snapd/21761
sda      64G
├─sda1  512M /boot/firmware    system-boot
└─sda2 63.5G /                 writable
</code></pre></div></div>

<h2 id="allowing-the-kernel-to-be-updated">Allowing the kernel to be updated</h2>

<p>So far, so good! But there’s one glaring issue that becomes apparent when a client tries to update its boot information at <code class="language-plaintext highlighter-rouge">/boot/firmware</code>. The bootloader is now getting this via TFTP, and being served an frozen extracted copy, rather than what it might have updated on <code class="language-plaintext highlighter-rouge">/dev/sda1</code>. This makes running system updates tricky!</p>

<h3 id="a-note-on-nfs-roots">A note on NFS roots</h3>

<p>If we were using NFS to serve the entire root filesystem, this would be easy enough to fix; we could bind mount the <code class="language-plaintext highlighter-rouge">boot/firmware</code> directory on the server to also be served via TFTP. Whilst this is technically circumventing the multi-access that the NFS server would manage, <code class="language-plaintext highlighter-rouge">tftp-hpa</code> is read-only by default, and the two access modes shouldn’t be required at the same time.</p>

<h3 id="the-problem-with-iscsi-roots">The problem with iSCSI roots</h3>

<p>From the perspective of the server, the block device being used for the iSCSI target is nothing more than that; it’s not mounted or readable by the server, nor should it be - unless the filesystem contained within is suitable for concurrent access (e.g. glusterfs), even a filesystem mounted a second time read-only won’t work. A filesystem being mounted read-only is not the same as the underlying block device being read-only (ext4 writes to its journal regardless of the mount flag), and regardless of that a non-clustered filesystem mount would cache reads and thus go stale.</p>

<h4 id="the-workaround">The workaround</h4>

<p>NFS to the rescue again - but we’ll instead use it to serve just the TFTP version of the boot partition back to the client, so it can re-mount it. With <code class="language-plaintext highlighter-rouge">nfs-kernel-server</code> installed, we can configure it:</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># cat /etc/exports
</span>/<span class="n">srv</span>/<span class="n">tftp</span>/<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span>-<span class="n">xx</span> <span class="m">192</span>.<span class="m">168</span>.<span class="m">20</span>.<span class="m">0</span>/<span class="m">24</span>(<span class="n">rw</span>,<span class="n">sync</span>,<span class="n">no_subtree_check</span>,<span class="n">no_root_squash</span>)
</code></pre></div></div>

<p>…and then apply the config with <code class="language-plaintext highlighter-rouge">sudo exportfs -a</code>.</p>

<p>Because boot files need to be owned by <code class="language-plaintext highlighter-rouge">root</code> and sometimes not even world-readable, we unfortunately need the <code class="language-plaintext highlighter-rouge">no_root_squash</code> option on the mount, and to punch some holes in the TFTP server’s configuration too:</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># in /etc/default/tftpd-hpa
</span>
<span class="c"># allow serving of files accessible only to root by running as root:
</span><span class="n">TFTP_USERNAME</span>=<span class="s2">"root"</span>

<span class="c"># 'secure' tries to sandbox access to just the TFTP directory
# 'permissive' skips extra checks that require world-readable files
</span><span class="n">TFTP_OPTIONS</span>=<span class="s2">"--secure --permissive"</span>
</code></pre></div></div>

<p>On the client, we’ll need to install <code class="language-plaintext highlighter-rouge">nfs-common</code>, then update <code class="language-plaintext highlighter-rouge">/etc/fstab</code>:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  LABEL=writable	/	ext4	defaults	0	1
<span class="gd">- LABEL=system-boot	/boot/firmware	vfat	defaults	0	1
</span><span class="gi">+ 192.168.20.1:/srv/tftp/xx-xx-xx-xx-xx-xx	/boot/firmware	nfs	defaults	0	1
</span></code></pre></div></div>

<p>…running <code class="language-plaintext highlighter-rouge">sudo mount -a</code> to apply.</p>

<p>It is a shame we have to manually install a package outside the base install on the client to get this working. Such provisioning is something that can be achieved with <code class="language-plaintext highlighter-rouge">cloud-init</code>, but that’s for another day.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>At this point, you might like to shut down the client, and take another set of snapshots with ZFS:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sudo zfs snapshot -r $CLIENT_DATASET@after-initial-config
</code></pre></div></div>

<p>In order to return to this state, we can use ZFS to roll back. Note that when rolling back, <code class="language-plaintext highlighter-rouge">-r</code> is used to remove any more recent snapshots than the targeted one, rather than meaning “recursive”. Rollbacks have to be done to each child individually:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>sudo zfs rollback -r $CLIENT_DATASET@after-initial-config
sudo zfs rollback -r $CLIENT_DATASET/vol@after-initial-config
sudo zfs rollback -r $CLIENT_DATASET/tftp@after-initial-config
</code></pre></div></div>]]></content><author><name>Josh Pencheon</name></author><category term="netboot" /><category term="networking" /><category term="raspberry-pi" /><category term="zfs" /><category term="iscsi" /><summary type="html"><![CDATA[In order to get a Raspberry Pi booting without any physically attached storage, we need to consider a number of different phases:]]></summary></entry></feed>