<?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: Mustafa ERBAY</title>
    <description>The latest articles on DEV Community by Mustafa ERBAY (@merbayerp).</description>
    <link>https://dev.to/merbayerp</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%2F3921203%2Fe3a198a1-49a0-466f-99e6-74bdf202a867.png</url>
      <title>DEV Community: Mustafa ERBAY</title>
      <link>https://dev.to/merbayerp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/merbayerp"/>
    <language>en</language>
    <item>
      <title>systemd-nspawn: Containers Without Images or Registries</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Mon, 07 Sep 2026 08:13:46 +0000</pubDate>
      <link>https://dev.to/merbayerp/systemd-nspawn-containers-without-images-or-registries-56n0</link>
      <guid>https://dev.to/merbayerp/systemd-nspawn-containers-without-images-or-registries-56n0</guid>
      <description>&lt;p&gt;The word "container" has collapsed into one meaning: pull an image, unpack the layers, tell the daemon, run it. But that isn't the only way to use the isolation the kernel offers — and one alternative is probably already packaged for your server.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;systemd-nspawn&lt;/code&gt; takes a directory and runs a full operating system inside it. No image format, no registry, no background daemon. In its own documentation's words it resembles &lt;code&gt;chroot&lt;/code&gt; but is far more powerful: alongside the file system hierarchy it virtualises the process tree, the various IPC subsystems, and the host and domain names.&lt;/p&gt;

&lt;p&gt;This article is about where that tool sits: what it isolates, what it doesn't, and the one condition for using it safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it isolate?
&lt;/h2&gt;

&lt;p&gt;The documentation's list is clear. From inside the container, access to kernel interfaces such as &lt;code&gt;/sys/&lt;/code&gt;, &lt;code&gt;/proc/sys/&lt;/code&gt; and &lt;code&gt;/sys/fs/selinux/&lt;/code&gt; is read-only; the host's network interfaces and the system clock can't be changed from within; device nodes may not be created; the host can't be rebooted and kernel modules can't be loaded.&lt;/p&gt;

&lt;p&gt;So most "accident" scenarios are closed. A build script can't change a &lt;code&gt;sysctl&lt;/code&gt; by mistake, an installer can't shift the host's clock.&lt;/p&gt;

&lt;p&gt;The practical distinction I draw from that: in its default form this list of restrictions prevents accidents. Confining malicious code is a separate matter, and the documentation is remarkably direct about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one condition: user namespaces
&lt;/h2&gt;

&lt;p&gt;The decisive sentence in the docs: if user namespaces are not used, this sandbox can easily be circumvented from within the container. The conclusion is equally clear — untrusted code must &lt;strong&gt;always&lt;/strong&gt; be run in a user namespace.&lt;/p&gt;

&lt;p&gt;The key is the &lt;code&gt;--private-users=&lt;/code&gt; option, which has several modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pick&lt;/code&gt;: turns on user namespacing and chooses the UID/GID range automatically. It reads a range from the root directory's owner, and if another container is using it, picks a new unused range between host UIDs 524288 and 1878982656. The cost isn't just time: this mode also triggers ownership adjustment, so the tree's file ownership on the host shifts permanently. Factor that in if you'll move the tree with &lt;code&gt;tar&lt;/code&gt;/&lt;code&gt;rsync&lt;/code&gt; or edit it from the host; that's why the documentation makes ownership behaviour configurable separately (&lt;code&gt;--private-users-ownership=&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;identity&lt;/code&gt;: an identity mapping for the first 65536 UIDs/GIDs. It gives no UID isolation, only capability isolation — and the docs say plainly that it "is not secure and must not be used to run untrusted code".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;managed&lt;/code&gt;: delegates UID range allocation to &lt;code&gt;systemd-nsresourced&lt;/code&gt;; this is the default when invoked unprivileged.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no&lt;/code&gt;: no user namespace at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upstream's own recommendation is explicit: in most cases &lt;code&gt;--private-users=managed&lt;/code&gt; — or &lt;code&gt;pick&lt;/code&gt; when you're privileged — is the recommended option, because user namespacing is advised for security and massively enhances container security. The same warning applies to &lt;code&gt;no&lt;/code&gt;: not secure, and not to be used for untrusted code.&lt;/p&gt;

&lt;p&gt;So before saying "it runs in a container, so I'm safe", check which mode you're in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtXaGF0IHdpbGwgeW91IHVzZSBzeXN0ZW1kLW5zcGF3biBmb3I_XSAtLT4gQntEbyB5b3UgdHJ1c3QgdGhlIGNvZGUgeW91IHJ1bj99CiAgQiAtLT58TXkgb3duIGNvZGUsIGZlYXIgb2YgYWNjaWRlbnRzfCBDW1RoZSBkZWZhdWx0IHNhbmRib3ggaXMgZW5vdWdoPGJyLz5yZWFkLW9ubHkgL3N5cywgL3Byb2Mvc3lzPGJyLz5ubyBkZXZpY2Ugbm9kZXMsIG5vIG1vZHVsZXNdCiAgQiAtLT58VW50cnVzdGVkIGNvZGV8IERbLS1wcml2YXRlLXVzZXJzPXBpY2sgaXMgUkVRVUlSRUQ8YnIvPmVzY2FwZSBpcyBlYXN5IHdpdGhvdXQgdXNlciBuYW1lc3BhY2VzXQogIEMgLS0-IEV7QSBwZXJzaXN0ZW50IHNlcnZpY2U_fQogIEQgLS0-IEUKICBFIC0tPnxZZXN8IEZbbWFjaGluZWN0bCArIHN5c3RlbWQtbnNwYXduQC5zZXJ2aWNlPGJyLz5hIC5uc3Bhd24gc2V0dGluZ3MgZmlsZV0KICBFIC0tPnxOb3wgR1tTdHJhaWdodCBmcm9tIHRoZSBjb21tYW5kIGxpbmU8YnIvPi0tZXBoZW1lcmFsIGZvciBhIHRocm93YXdheSBjb3B5XQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtXaGF0IHdpbGwgeW91IHVzZSBzeXN0ZW1kLW5zcGF3biBmb3I_XSAtLT4gQntEbyB5b3UgdHJ1c3QgdGhlIGNvZGUgeW91IHJ1bj99CiAgQiAtLT58TXkgb3duIGNvZGUsIGZlYXIgb2YgYWNjaWRlbnRzfCBDW1RoZSBkZWZhdWx0IHNhbmRib3ggaXMgZW5vdWdoPGJyLz5yZWFkLW9ubHkgL3N5cywgL3Byb2Mvc3lzPGJyLz5ubyBkZXZpY2Ugbm9kZXMsIG5vIG1vZHVsZXNdCiAgQiAtLT58VW50cnVzdGVkIGNvZGV8IERbLS1wcml2YXRlLXVzZXJzPXBpY2sgaXMgUkVRVUlSRUQ8YnIvPmVzY2FwZSBpcyBlYXN5IHdpdGhvdXQgdXNlciBuYW1lc3BhY2VzXQogIEMgLS0-IEV7QSBwZXJzaXN0ZW50IHNlcnZpY2U_fQogIEQgLS0-IEUKICBFIC0tPnxZZXN8IEZbbWFjaGluZWN0bCArIHN5c3RlbWQtbnNwYXduQC5zZXJ2aWNlPGJyLz5hIC5uc3Bhd24gc2V0dGluZ3MgZmlsZV0KICBFIC0tPnxOb3wgR1tTdHJhaWdodCBmcm9tIHRoZSBjb21tYW5kIGxpbmU8YnIvPi0tZXBoZW1lcmFsIGZvciBhIHRocm93YXdheSBjb3B5XQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="586" height="1122"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  No images, just directories
&lt;/h2&gt;

&lt;p&gt;This is the big mental shift. In Docker you pull an image; with nspawn you prepare an operating system tree. The docs name the tools too: &lt;code&gt;dnf&lt;/code&gt;, &lt;code&gt;debootstrap&lt;/code&gt; or &lt;code&gt;pacman&lt;/code&gt; set up a filesystem hierarchy suitable for the job.&lt;/p&gt;

&lt;p&gt;The location is standardised as well: &lt;code&gt;/var/lib/machines/&lt;/code&gt; is the suggested directory for OS container images installed on the system. When you pass &lt;code&gt;--machine=&lt;/code&gt;, a tree is searched for in a couple of locations, most importantly there.&lt;/p&gt;

&lt;p&gt;There's a small but annoying check: when &lt;strong&gt;booting&lt;/strong&gt; a container, nspawn verifies that &lt;code&gt;/usr/lib/os-release&lt;/code&gt; or &lt;code&gt;/etc/os-release&lt;/code&gt; exists in the tree. If you're only opening a shell the check doesn't apply; if you're booting a very old distribution tree you may have to add the file by hand.&lt;/p&gt;

&lt;p&gt;The most useful option while experimenting is &lt;code&gt;--ephemeral&lt;/code&gt;: the container runs on a temporary snapshot of its file system, which is removed when it terminates. The docs add two notes: taking that snapshot is far more efficient on file systems with native subvolume snapshots or reflinks (btrfs or new xfs), and the switch leaves hostname, machine ID and other identifying settings unmodified. The second matters in CI: launch parallel runs from the same tree and you get two containers with the same machine ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first container: four commands
&lt;/h2&gt;

&lt;p&gt;The shortest path to making this concrete. On a Debian-based host, set up a Debian tree and step into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;systemd-container debootstrap
debootstrap &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;systemd,dbus stable /var/lib/machines/demo
systemd-nspawn &lt;span class="nt"&gt;--directory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/machines/demo &lt;span class="nt"&gt;--private-users&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pick
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third command drops you into a shell inside the container. To boot the full OS with its init you add &lt;code&gt;--boot&lt;/code&gt; — which is already the default when you run it as a service. On a fresh debootstrap tree the root password is locked, so &lt;code&gt;--boot&lt;/code&gt; leaves you at a login prompt you can't get past; set the password first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-nspawn &lt;span class="nt"&gt;--directory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/machines/demo passwd root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To run it as a service, &lt;code&gt;machinectl&lt;/code&gt; steps in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;machinectl start demo         &lt;span class="c"&gt;# runs as systemd-nspawn@demo.service&lt;/span&gt;
machinectl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; demo  &lt;span class="c"&gt;# use this if it should also start at boot&lt;/span&gt;
machinectl shell demo         &lt;span class="c"&gt;# open a shell inside&lt;/span&gt;
machinectl poweroff demo      &lt;span class="c"&gt;# shut it down cleanly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;start&lt;/code&gt; is one-shot; if you want the container up after a reboot you need &lt;code&gt;enable&lt;/code&gt;, which the documentation describes as having the same effect as &lt;code&gt;systemctl enable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The container is now an ordinary systemd service, and resource control happens the ordinary way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl set-property systemd-nspawn@demo.service &lt;span class="nv"&gt;MemoryMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2G &lt;span class="nv"&gt;CPUQuota&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't learn a separate "container resource management" layer. But know the split: those settings belong to the unit; the &lt;code&gt;.nspawn&lt;/code&gt; file doesn't express cgroup limits, where the equivalents are limited to &lt;code&gt;Limit*&lt;/code&gt; (rlimits), &lt;code&gt;CPUAffinity&lt;/code&gt; and &lt;code&gt;OOMScoreAdjust&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Networking: shared by default
&lt;/h2&gt;

&lt;p&gt;Networking in nspawn depends on how you start it. From the command line, if you say nothing, the container shares the host's network stack — practical for a quick test, insufficient for isolation. Started as a service, the template unit applies &lt;code&gt;--network-veth&lt;/code&gt;, so the container is born in its own network namespace. Don't go looking for the host network in a container you started with &lt;code&gt;machinectl start&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--private-network&lt;/code&gt; gives the container its own network namespace; &lt;code&gt;--network-veth&lt;/code&gt; creates a virtual Ethernet link between host and container, with the container-side interface named &lt;code&gt;host0&lt;/code&gt;. The latter implies the former.&lt;/p&gt;

&lt;p&gt;Here's the built-in convenience: &lt;code&gt;systemd-networkd&lt;/code&gt; ships two files by default — one matching the host-side &lt;code&gt;ve-*&lt;/code&gt; interfaces, enabling automatic address provisioning over DHCP and automatic IP routing onto the host's external interfaces, and one matching the container-side &lt;code&gt;host0&lt;/code&gt; interface, enabling client-side address assignment via DHCP. So with &lt;code&gt;systemd-networkd&lt;/code&gt;, networking usually "just works"; without it, you set up bridging and NAT yourself.&lt;/p&gt;

&lt;p&gt;In unprivileged mode the options narrow: only &lt;code&gt;--private-network&lt;/code&gt; and &lt;code&gt;--network-veth&lt;/code&gt; are supported. More importantly, the first item on that same list limits this article's framing directly — in unprivileged mode only disk-image-based containers are supported; directory-based ones work only if owned by the "foreign" UID range.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it as a service
&lt;/h2&gt;

&lt;p&gt;If you'd rather not start the container by hand, the infrastructure is there: &lt;code&gt;machinectl&lt;/code&gt; provides commands to run containers as system services through the &lt;code&gt;systemd-nspawn@.service&lt;/code&gt; template unit. Each container instance runs as its own service instance, with the container name as the instance identifier.&lt;/p&gt;

&lt;p&gt;There are behavioural differences that catch people out, and they touch both of this article's main themes. Here's the command line the template unit runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ExecStart=systemd-nspawn --quiet --keep-unit --boot --link-journal=try-guest           --network-veth -U --settings=override --machine=%i
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note three things: &lt;code&gt;--boot&lt;/code&gt; starts the init process inside the container (not the default from the command line), &lt;code&gt;-U&lt;/code&gt; already turns on user namespacing (so on the service path the template handles &lt;code&gt;--private-users&lt;/code&gt; for you), and &lt;code&gt;--network-veth&lt;/code&gt; puts the container in its own network namespace. Thanks to &lt;code&gt;--link-journal=try-guest&lt;/code&gt;, the container's logs are readable from the host with &lt;code&gt;journalctl -M &amp;lt;name&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;"It worked on the command line but behaved differently as a service" usually traces back to these three.&lt;/p&gt;

&lt;p&gt;You don't have to bury settings in the unit file either: a settings file with the &lt;code&gt;.nspawn&lt;/code&gt; suffix may sit alongside each container, and it overrides the template unit's defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  It also runs OCI bundles
&lt;/h2&gt;

&lt;p&gt;Version footing matters here: some of these options are recent. The &lt;code&gt;managed&lt;/code&gt; mode and the maturing of unprivileged nspawn arrived with systemd 258; &lt;code&gt;--mstack=&lt;/code&gt; for stacking image layers and &lt;code&gt;importctl pull-oci&lt;/code&gt; with 260. On Debian 12 (systemd 252) you'll find none of them.&lt;/p&gt;

&lt;p&gt;A lesser-known option is &lt;code&gt;--oci-bundle=&lt;/code&gt;, which takes the path to an OCI runtime bundle as specified in the OCI Runtime Specification. In that case no &lt;code&gt;.nspawn&lt;/code&gt; file is loaded; the root directory and various settings are read from the OCI runtime JSON data, with command-line data taking precedence.&lt;/p&gt;

&lt;p&gt;In current systemd the connection is closer still: OCI images pulled with &lt;code&gt;importctl pull-oci&lt;/code&gt; can be stored as a stack and run with nspawn. So treating nspawn as an island closed off from the container ecosystem is wrong; the accurate statement is that the &lt;code&gt;systemd-nspawn&lt;/code&gt; binary doesn't do image distribution — that job lives in a separate systemd component (&lt;code&gt;importctl&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  The state on my server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dpkg &lt;span class="nt"&gt;-l&lt;/span&gt; systemd-container | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="gp"&gt;un  systemd-container &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;lt;none&amp;gt; &lt;span class="o"&gt;(&lt;/span&gt;no description available&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /var/lib/machines
&lt;span class="go"&gt;ls: cannot access '/var/lib/machines': No such file or directory

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/user/max_user_namespaces
&lt;span class="go"&gt;386072
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;un&lt;/code&gt; on the first line means the package isn't installed: on Ubuntu, &lt;code&gt;systemd-nspawn&lt;/code&gt; and &lt;code&gt;machinectl&lt;/code&gt; ship in a separate package, and this machine doesn't have it. So "systemd is everywhere, therefore nspawn is everywhere" is wrong.&lt;/p&gt;

&lt;p&gt;The third line shows user namespaces are plentiful — but that isn't the only place to look. The same machine also has this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sysctl kernel.apparmor_restrict_unprivileged_userns
&lt;span class="go"&gt;kernel.apparmor_restrict_unprivileged_userns = 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ubuntu 24.04 restricts unprivileged processes from creating user namespaces via AppArmor. It doesn't stop you using &lt;code&gt;--private-users=pick&lt;/code&gt; as root, but it's the wall you'll hit if you try unprivileged nspawn. On Debian the equivalent is &lt;code&gt;kernel.unprivileged_userns_clone&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instead of Docker, or alongside it?
&lt;/h2&gt;

&lt;p&gt;The comparison needs the right axis. Docker/Podman brought two things: &lt;strong&gt;distribution&lt;/strong&gt; (image format, registry, layer sharing, versioning) and &lt;strong&gt;security defaults&lt;/strong&gt; (a seccomp profile, dropped capabilities, rootless plus user namespaces in Podman). nspawn does none of the first, and its default on the second is weaker — you switch security on yourself with &lt;code&gt;--private-users&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What nspawn is good at is running a full operating system. If you want an environment with systemd inside, several services, "like a small VM but not a VM", it's the natural choice. Typical uses: building and testing on different distribution releases, exercising systemd units under a real init, keeping a legacy application alive with its own distribution tree.&lt;/p&gt;

&lt;p&gt;If you're running one process per application — today's microservice pattern — there's no reason to reach for nspawn. If you want a system container and you're already in the systemd world, it solves that without installing another daemon.&lt;/p&gt;

&lt;p&gt;You can build a similar setup with the approach in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/podman-quadlet-ile-systemd-altinda-rootless-container/" rel="noopener noreferrer"&gt;my article on rootless containers under systemd with Podman quadlets&lt;/a&gt;; the difference is that quadlets bind OCI images to systemd, while nspawn doesn't use the concept of an image at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Verify the package is installed; the presence of systemd doesn't imply the presence of nspawn.&lt;/li&gt;
&lt;li&gt;If you'll run untrusted code, don't start without &lt;code&gt;--private-users=pick&lt;/code&gt;; the &lt;code&gt;identity&lt;/code&gt; mode provides no security and the docs say so explicitly.&lt;/li&gt;
&lt;li&gt;Prefer &lt;code&gt;--ephemeral&lt;/code&gt; for experiments and CI; account for the snapshot cost if your filesystem isn't btrfs or new xfs.&lt;/li&gt;
&lt;li&gt;If you run it as a service, know the &lt;code&gt;--boot&lt;/code&gt; difference; command-line behaviour and service behaviour aren't the same.&lt;/li&gt;
&lt;li&gt;Keep settings in a &lt;code&gt;.nspawn&lt;/code&gt; file rather than editing the unit.&lt;/li&gt;
&lt;li&gt;Choose the network model up front: in unprivileged mode only private network and veth are supported.&lt;/li&gt;
&lt;li&gt;Don't forget the &lt;code&gt;os-release&lt;/code&gt; file when building the OS tree; nspawn checks for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A container isn't one thing
&lt;/h2&gt;

&lt;p&gt;What interests me most about nspawn is how narrow the word "container" has become. There is no container object on the kernel side; there are namespaces, cgroups and capabilities. Every tool built on top composes those parts differently.&lt;/p&gt;

&lt;p&gt;Docker built its composition around distribution and won. nspawn never enters the distribution business and solves only the running side — which is why you hear about it less, and why it stays simpler for some jobs.&lt;/p&gt;

&lt;p&gt;So the question for your own setup: is your reason for containers image distribution, running a full operating system, or a tight security boundary? For the first, Docker/Podman; for the second, nspawn; for the third, a tool whose defaults are set in your favour — or nspawn with user namespaces on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/systemd/blob/main/man/systemd-nspawn.xml" rel="noopener noreferrer"&gt;systemd-nspawn(1) — the upstream source of the man page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/systemd/blob/main/man/systemd.nspawn.xml" rel="noopener noreferrer"&gt;systemd.nspawn(5) — container settings files&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/plain/man/man7/user_namespaces.7" rel="noopener noreferrer"&gt;user_namespaces(7) — the semantics of user namespaces&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://packages.debian.org/trixie/systemd-container" rel="noopener noreferrer"&gt;Debian: the systemd-container package&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>systemd</category>
      <category>containers</category>
      <category>security</category>
    </item>
    <item>
      <title>The Clock You Can't Stop: PostgreSQL XID Wraparound</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Mon, 07 Sep 2026 08:05:51 +0000</pubDate>
      <link>https://dev.to/merbayerp/the-clock-you-cant-stop-postgresql-xid-wraparound-506d</link>
      <guid>https://dev.to/merbayerp/the-clock-you-cant-stop-postgresql-xid-wraparound-506d</guid>
      <description>&lt;p&gt;There's a clock counting down in the background of PostgreSQL, and most teams notice it for the first time when it has nearly run out.&lt;/p&gt;

&lt;p&gt;The clock is the transaction ID — XID for short. Every write takes an XID, and the counter is 32-bit, so it wraps after four billion transactions. In the circular scheme the documentation describes, each XID has roughly two billion "older" and two billion "newer" neighbours; when the counter wraps, old transactions suddenly appear to be in the future, which means catastrophic data loss.&lt;/p&gt;

&lt;p&gt;That's why PostgreSQL carries a safeguard: it &lt;strong&gt;freezes&lt;/strong&gt; rows. VACUUM marks sufficiently old row versions as frozen; frozen rows appear to be in the past to all normal transactions no matter what the counter does, and stay valid until deleted.&lt;/p&gt;

&lt;p&gt;This article is about that clock: how to read it, what happens at each threshold, and what not to do when the alarm goes off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the queries return on a trouble-free cluster
&lt;/h2&gt;

&lt;p&gt;Real output from a PostgreSQL 16 instance on my own server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;psql &lt;span class="nt"&gt;-At&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"select name, setting from pg_settings where name in
&lt;/span&gt;&lt;span class="go"&gt;    ('server_version','autovacuum_freeze_max_age','vacuum_freeze_min_age',
     'vacuum_freeze_table_age','vacuum_failsafe_age',
&lt;/span&gt;&lt;span class="gp"&gt;     'autovacuum_multixact_freeze_max_age') order by name;&lt;/span&gt;&lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;autovacuum_freeze_max_age|200000000
autovacuum_multixact_freeze_max_age|400000000
server_version|16.13
vacuum_failsafe_age|1600000000
vacuum_freeze_min_age|50000000
vacuum_freeze_table_age|150000000

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;psql &lt;span class="nt"&gt;-At&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"select datname, age(datfrozenxid) from pg_database order by 2 desc;"&lt;/span&gt;
&lt;span class="go"&gt;postgres|4259
burcu_mutfak|4259
template1|4259
template0|4259
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This output comes from a trouble-free cluster: an age of 4,259 is about two hundred-thousandths of the forced-intervention threshold at 200 million. To be honest, this table proves the cluster is young and quiet rather than proving how well freezing works — all four databases sitting at the same value is the giveaway. What matters more is knowing what these two queries say. &lt;code&gt;age(datfrozenxid)&lt;/code&gt; gives the distance between the oldest unfrozen XID in that database and the current XID. As the number grows, the clock advances.&lt;/p&gt;

&lt;p&gt;For a per-table view the documentation provides the query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;greatest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relfrozenxid&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relfrozenxid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reltoastrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'m'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common misconception needs correcting here: &lt;code&gt;datfrozenxid&lt;/code&gt; is, in the documentation's words, just the minimum of the per-table &lt;code&gt;relfrozenxid&lt;/code&gt; values in that database. So the database age equals the age of its oldest table; "the database looks fine but one table is behind" can't happen. The per-table query's value is different: the database-level number tells you &lt;em&gt;something&lt;/em&gt; has fallen behind, while only this list tells you &lt;em&gt;which&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;greatest(...)&lt;/code&gt; in the query is no accident either: a table's TOAST table can be the one that's actually behind, so the larger of the two is taken. One more note: &lt;code&gt;datfrozenxid&lt;/code&gt; is only recomputed when a vacuum updates it, so the number you see isn't live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thresholds: four separate lines
&lt;/h2&gt;

&lt;p&gt;The parameter names look alike, so they get confused; in fact the four look at four different moments.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vacuum_freeze_min_age&lt;/code&gt; (default 50 million) is the minimum age a row version needs before it can be frozen. A lower value means more freezing work, a higher one less work but later protection.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vacuum_freeze_table_age&lt;/code&gt; (default 150 million) is the threshold where a normal VACUUM switches to aggressive mode — visiting every page that might contain unfrozen XIDs, not just those that might contain dead tuples. The documentation notes its effective maximum is 0.95 × &lt;code&gt;autovacuum_freeze_max_age&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;autovacuum_freeze_max_age&lt;/code&gt; (default 200 million) is where forced anti-wraparound autovacuum kicks in. It runs even if autovacuum is disabled; at that point PostgreSQL doesn't consult your preference.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vacuum_failsafe_age&lt;/code&gt; (default 1.6 billion) is the strategy of last resort: at that age any cost-based delay stops applying, index cleanup is skipped, and the buffer access strategy is disabled so vacuum can use all of shared buffers. If you're here, you're in recovery. There's a linked behaviour too: raise &lt;code&gt;autovacuum_freeze_max_age&lt;/code&gt; and the failsafe is silently adjusted to at least 105% of it.&lt;/p&gt;

&lt;p&gt;Multixacts have their own counter as well: &lt;code&gt;autovacuum_multixact_freeze_max_age&lt;/code&gt;, 400 million in my instance. On systems making heavy use of &lt;code&gt;SELECT ... FOR SHARE&lt;/code&gt; or foreign key locks this counter can fill before the XID one — and hardly anyone watches it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtBZ2UgZ3Jvd3M8YnIvPmFnZSBkYXRmcm96ZW54aWRdIC0tPiBCezUwTTogdmFjdXVtX2ZyZWV6ZV9taW5fYWdlfQogIEIgLS0-IENbUm93cyBiZWNvbWUgZWxpZ2libGUgZm9yIGZyZWV6aW5nXQogIEMgLS0-IER7MTUwTTogdmFjdXVtX2ZyZWV6ZV90YWJsZV9hZ2V9CiAgRCAtLT4gRVtWQUNVVU0gZ29lcyBhZ2dyZXNzaXZlPGJyLz5zY2FucyB0aGUgd2hvbGUgdGFibGVdCiAgRSAtLT4gRnsyMDBNOiBhdXRvdmFjdXVtX2ZyZWV6ZV9tYXhfYWdlfQogIEYgLS0-IEdbRm9yY2VkIGFudGktd3JhcGFyb3VuZCBhdXRvdmFjdXVtPGJyLz5ldmVuIGlmIGF1dG92YWN1dW0gaXMgb2ZmXQogIEcgLS0-IEh7MS42IGJpbGxpb246IHZhY3V1bV9mYWlsc2FmZV9hZ2V9CiAgSCAtLT4gSVtFbWVyZ2VuY3kgbW9kZTogY29zdCBkZWxheSBhbmQ8YnIvPmluZGV4IGNsZWFudXAgYXJlIGRyb3BwZWRdCiAgSSAtLT4gSnt-MiBiaWxsaW9uOiBoYXJkIGxpbWl0fQogIEogLS0-IEtbTm8gbmV3IFhJRHMgYXNzaWduZWQ8YnIvPndyaXRlcyBhcmUgcmVmdXNlZF0%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtBZ2UgZ3Jvd3M8YnIvPmFnZSBkYXRmcm96ZW54aWRdIC0tPiBCezUwTTogdmFjdXVtX2ZyZWV6ZV9taW5fYWdlfQogIEIgLS0-IENbUm93cyBiZWNvbWUgZWxpZ2libGUgZm9yIGZyZWV6aW5nXQogIEMgLS0-IER7MTUwTTogdmFjdXVtX2ZyZWV6ZV90YWJsZV9hZ2V9CiAgRCAtLT4gRVtWQUNVVU0gZ29lcyBhZ2dyZXNzaXZlPGJyLz5zY2FucyB0aGUgd2hvbGUgdGFibGVdCiAgRSAtLT4gRnsyMDBNOiBhdXRvdmFjdXVtX2ZyZWV6ZV9tYXhfYWdlfQogIEYgLS0-IEdbRm9yY2VkIGFudGktd3JhcGFyb3VuZCBhdXRvdmFjdXVtPGJyLz5ldmVuIGlmIGF1dG92YWN1dW0gaXMgb2ZmXQogIEcgLS0-IEh7MS42IGJpbGxpb246IHZhY3V1bV9mYWlsc2FmZV9hZ2V9CiAgSCAtLT4gSVtFbWVyZ2VuY3kgbW9kZTogY29zdCBkZWxheSBhbmQ8YnIvPmluZGV4IGNsZWFudXAgYXJlIGRyb3BwZWRdCiAgSSAtLT4gSnt-MiBiaWxsaW9uOiBoYXJkIGxpbWl0fQogIEogLS0-IEtbTm8gbmV3IFhJRHMgYXNzaWduZWQ8YnIvPndyaXRlcyBhcmUgcmVmdXNlZF0%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="309" height="2351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the server says when the alarm rings
&lt;/h2&gt;

&lt;p&gt;Once you enter the warning phase, the server starts speaking on every transaction. But you need to know &lt;strong&gt;how late&lt;/strong&gt; that warning is: in the source, the warning limit is set 40 million transactions before the wraparound point — that is, when age reaches 2.1 billion. The hard limit follows just 3 million transactions later. This isn't an early warning, it's the last exit sign. (In the PostgreSQL 19 development branch that window was widened to 100 million; still late.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARNING:  database "mydb" must be vacuumed within 39985967 transactions
HINT:  To avoid XID assignment failures, execute a database-wide VACUUM in that database.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the hard limit, work stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  database is not accepting commands that assign new transaction IDs
to avoid wraparound data loss in database "mydb"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documentation's wording is precise: transactions already in progress can continue, but only read-only transactions can be started; operations that modify records or truncate relations fail. VACUUM still runs normally — that's your way out. And don't wait on that vacuum blindly: the &lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt; view shows which phase it's in and how far it has got.&lt;/p&gt;

&lt;p&gt;These messages also vary by version; the ones above are from the current documentation. PostgreSQL 16 words the error slightly differently, so if you're writing log matching, check your own version's text.&lt;/p&gt;

&lt;p&gt;The logic in the source code describes the same three tiers: past &lt;code&gt;xidVacLimit&lt;/code&gt; autovacuum starts being forced, past &lt;code&gt;xidWarnLimit&lt;/code&gt; warnings are issued, and past &lt;code&gt;xidStopLimit&lt;/code&gt; executing transactions is refused outside single-user mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does age grow? Usually vacuum isn't the culprit
&lt;/h2&gt;

&lt;p&gt;The most common mistake here is diving into autovacuum settings when the alarm rings. Vacuum is usually ready to work; something is blocking it. The documentation lists these blockers among the recovery steps, and in practice you read the list backwards:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unfinished prepared transactions&lt;/strong&gt;: a row in &lt;code&gt;pg_prepared_xacts&lt;/code&gt; with a large age means nothing beyond that XID can be considered freezable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-running transactions&lt;/strong&gt;: large &lt;code&gt;age(backend_xid)&lt;/code&gt; or &lt;code&gt;age(backend_xmin)&lt;/code&gt; values in &lt;code&gt;pg_stat_activity&lt;/code&gt;. A forgotten &lt;code&gt;BEGIN&lt;/code&gt;, a psql session left open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale replication slots&lt;/strong&gt;: large &lt;code&gt;age(xmin)&lt;/code&gt; or &lt;code&gt;age(catalog_xmin)&lt;/code&gt; in &lt;code&gt;pg_replication_slots&lt;/code&gt;. A replica that was torn down may have left a slot holding things back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feedback from replicas&lt;/strong&gt;: with &lt;code&gt;hot_standby_feedback&lt;/code&gt; on, rows the standby needs are held on the primary; &lt;code&gt;pg_stat_replication.backend_xmin&lt;/code&gt; shows how far behind that is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Until those three are cleared, age doesn't drop however much vacuum runs. Tuning autovacuum itself is another matter; the thresholds I covered in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/postgresql-vacuum-ve-bloat-autovacuumu-dogru-ayarlamak/" rel="noopener noreferrer"&gt;the PostgreSQL VACUUM and bloat article&lt;/a&gt; apply to the bloat side, while on the wraparound side the priority is removing blockers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Insert-only tables: the classic trap
&lt;/h2&gt;

&lt;p&gt;One classic source of wraparound trouble is tables that are never updated. Event logs, time series, audit trails — tables that constantly receive rows but are almost never updated or deleted.&lt;/p&gt;

&lt;p&gt;Classic autovacuum ignored those tables, because its trigger was the dead tuple count and there were no dead tuples. Yet the freezing need was there: every inserted row carries an XID. &lt;code&gt;autovacuum_vacuum_insert_threshold&lt;/code&gt;, added in PostgreSQL 13, closed that gap; the default is 1000 inserted tuples, and &lt;code&gt;-1&lt;/code&gt; disables it entirely.&lt;/p&gt;

&lt;p&gt;On clusters upgraded from older versions, or setups that turned this off as "unnecessary I/O", the trap is still there: inserted rows are never frozen, age grows quietly, and when the alarm rings the table that's behind is usually one of these. Remember that partitioned time series carry a &lt;code&gt;relfrozenxid&lt;/code&gt; per partition — partitions show up as separate rows in the table list.&lt;/p&gt;

&lt;p&gt;PostgreSQL 18 changed this picture somewhat: with &lt;code&gt;vacuum_max_eager_freeze_failure_rate&lt;/code&gt; (default 0.03), a normal vacuum will try to proactively freeze all-visible but not-all-frozen pages. So on 18 and later the risk of append-only tables quietly falling behind is reduced; on older versions it's unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock conflicts: what stops vacuum
&lt;/h2&gt;

&lt;p&gt;Another subtle mechanism lives in locks. Under normal conditions autovacuum doesn't block other commands: if a process requests a lock conflicting with the &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; lock autovacuum holds, the autovacuum is interrupted and gets out of the way.&lt;/p&gt;

&lt;p&gt;The subtlety: that behaviour doesn't apply to anti-wraparound vacuum. The documentation is explicit — if the autovacuum is running to prevent wraparound (its query name in &lt;code&gt;pg_stat_activity&lt;/code&gt; ends with "(to prevent wraparound)"), it is not automatically interrupted. The docs also warn that regularly running commands which take locks conflicting with &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; — &lt;code&gt;ANALYZE&lt;/code&gt;, for instance — can effectively prevent autovacuum from doing its job.&lt;/p&gt;

&lt;p&gt;The operational translation runs both ways. On one hand, if your scheduled maintenance keeps interrupting vacuum, table age grows quietly. On the other, once things reach the anti-wraparound stage vacuum no longer yields; an &lt;code&gt;ALTER TABLE&lt;/code&gt; starts waiting and the team panics that "the database is locked up". Two ends of the same story.&lt;/p&gt;

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

&lt;p&gt;Two reflexes during recovery make things worse.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;VACUUM FULL&lt;/code&gt;. The documentation warns plainly: it requires an XID and will therefore fail — except in super-user mode, where it instead consumes an XID and thus increases the wraparound risk. Second, &lt;code&gt;VACUUM FREEZE&lt;/code&gt;: it does more work than needed and burns time you don't have. The right command is plain &lt;code&gt;VACUUM&lt;/code&gt;, run as a superuser so system catalogues are processed too.&lt;/p&gt;

&lt;p&gt;A third, older reflex: dropping into single-user mode. The documentation says this is no longer necessary in typical scenarios, should be avoided since it takes the system down, and is riskier — because that mode disables the wraparound safeguards designed to prevent data loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost on the storage side
&lt;/h2&gt;

&lt;p&gt;Raising &lt;code&gt;autovacuum_freeze_max_age&lt;/code&gt; is tempting: vacuum runs less often, I/O drops. The price is growth in &lt;code&gt;pg_xact&lt;/code&gt; and &lt;code&gt;pg_commit_ts&lt;/code&gt;. The documentation gives concrete sizes: at the maximum (2 billion), &lt;code&gt;pg_xact&lt;/code&gt; is about 500 MB and &lt;code&gt;pg_commit_ts&lt;/code&gt; about 20 GB; at the default 200 million, about 50 MB and 2 GB respectively.&lt;/p&gt;

&lt;p&gt;So this parameter isn't a "performance setting" but a deliberate trade between disk and vacuum work. And the mandatory rule doesn't change: every table in every database must be vacuumed at least once every two billion transactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring: a one-line insurance policy
&lt;/h2&gt;

&lt;p&gt;The nice thing about this topic is how cheap the monitoring is. One query and one threshold suffice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datfrozenxid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_database&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But put the threshold in the right place. Alerting on a percentage of &lt;code&gt;autovacuum_freeze_max_age&lt;/code&gt; is misleading: with the defaults, reaching 150 million (75% of that value) is normal — aggressive vacuum triggers exactly there. On a busy cluster, moving inside that band is a sign of healthy operation.&lt;/p&gt;

&lt;p&gt;Three better signals: (1) is the &lt;code&gt;max(age(datfrozenxid))&lt;/code&gt; trend monotonically rising — i.e. does vacuum never bring the age down; (2) is there a vacuum ending in "(to prevent wraparound)" in &lt;code&gt;pg_stat_activity&lt;/code&gt;; (3) is the age approaching &lt;code&gt;vacuum_failsafe_age&lt;/code&gt; (1.6 billion by default). If you want absolute thresholds, put the warning around 1 billion and the critical below the failsafe.&lt;/p&gt;

&lt;p&gt;As a checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Alert on &lt;code&gt;max(age(datfrozenxid))&lt;/code&gt;; define the threshold as a percentage of &lt;code&gt;autovacuum_freeze_max_age&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Watch the multixact side with a query: &lt;code&gt;select datname, mxid_age(datminmxid) from pg_database order by 2 desc;&lt;/code&gt; — separate counter, separate threshold (&lt;code&gt;autovacuum_multixact_freeze_max_age&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;On a managed service (RDS, Aurora, Cloud SQL) there's no superuser and no single-user mode; you manage parameters through the provider's parameter group and put the provider's wraparound metric on your dashboard.&lt;/li&gt;
&lt;li&gt;Run a monthly blocker sweep: &lt;code&gt;pg_prepared_xacts&lt;/code&gt;, long transactions, unused replication slots.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;autovacuum_vacuum_insert_threshold&lt;/code&gt; on insert-only tables; if it's disabled, age grows silently.&lt;/li&gt;
&lt;li&gt;Look for vacuums ending in "(to prevent wraparound)" in &lt;code&gt;pg_stat_activity&lt;/code&gt;: that's emergency vacuum, not routine maintenance, and it makes lock requesters wait.&lt;/li&gt;
&lt;li&gt;Put the top five oldest tables on your dashboard; trouble usually starts in one table, not in the database average.&lt;/li&gt;
&lt;li&gt;Write "don't use VACUUM FULL or VACUUM FREEZE" into the recovery runbook; those are exactly what comes to mind in a crisis.&lt;/li&gt;
&lt;li&gt;Check the age before an upgrade: &lt;code&gt;pg_upgrade&lt;/code&gt; preserves freeze information, but the post-upgrade analyze and the aggressive vacuum it triggers can stretch the maintenance window you planned.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Counters fill quietly
&lt;/h2&gt;

&lt;p&gt;XID wraparound reminds me of the sneakiest part of running databases: some problems don't arrive as a slowdown, they arrive one day as a wall.&lt;/p&gt;

&lt;p&gt;CPU and memory metrics warn you gradually; a counter filling up advances linearly and gives nothing away until the last moment. Then writes are refused and all you can do is wait for vacuum to finish.&lt;/p&gt;

&lt;p&gt;So the question for your own setup: which of your counters advance linearly, and how many of them do you have alerts on? In PostgreSQL, XID is the best known — but not the only one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/routine-vacuuming.html" rel="noopener noreferrer"&gt;PostgreSQL: Routine Vacuuming — preventing transaction ID wraparound failures&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/runtime-config-autovacuum.html" rel="noopener noreferrer"&gt;PostgreSQL: vacuum and autovacuum runtime parameters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/postgres/postgres/blob/master/src/backend/access/transam/varsup.c" rel="noopener noreferrer"&gt;PostgreSQL source: XID limits and warning tiers (varsup.c)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>operations</category>
      <category>reliability</category>
    </item>
    <item>
      <title>Lazy Pulling: The Part of the Image Nobody Reads</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:43:45 +0000</pubDate>
      <link>https://dev.to/merbayerp/lazy-pulling-the-part-of-the-image-nobody-reads-4k56</link>
      <guid>https://dev.to/merbayerp/lazy-pulling-the-part-of-the-image-nobody-reads-4k56</guid>
      <description>&lt;p&gt;When a container comes up, most of the time goes into one job: downloading the image. The application itself starts in milliseconds, but gigabytes of layers have to be pulled and unpacked first. On a workload that scales, this decides the answer to "when does the new pod take traffic".&lt;/p&gt;

&lt;p&gt;The annoying part is that most of the downloaded data is never read. There's a study behind that intuition, and it's the starting point for the lazy-pulling projects: as the SOCI snapshotter's documentation relays, Harter et al's 2016 FAST '16 work found that on a representative suite of images, image download accounts for 76% of container startup time, while on average only 6.4% of the fetched data is actually needed for the container to start doing useful work. Carry those numbers into 2026 with care: the images of that era aren't today's multi-gigabyte model images, and networks got faster. The direction holds; the magnitude depends on your image.&lt;/p&gt;

&lt;p&gt;Lazy pulling builds on that observation: start the container without downloading the whole image, and fetch files in the background as they're read. The idea isn't new, but today it has several mature implementations: eStargz, &lt;code&gt;zstd:chunked&lt;/code&gt;, SOCI and nydus. All of them work with standard registries; this article looks at the first three.&lt;/p&gt;

&lt;p&gt;What I want to convey here is as much about which guarantee you keep in which stack as about the mechanism itself. Because "lazy pulling" isn't one thing: two implementations of the same idea come with two different security positions, and the difference hides in the name of a configuration option that's easy to skim past.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens on my own server
&lt;/h2&gt;

&lt;p&gt;First, the ordinary case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker info | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Server Version|Storage Driver"&lt;/span&gt;
&lt;span class="go"&gt; Server Version: 29.4.3
 Storage Driver: overlayfs
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right now this machine pulls images the classic way: all layers come down, get unpacked, and then the container starts. But the &lt;code&gt;overlayfs&lt;/code&gt; detail in that output matters — that's not the old &lt;code&gt;overlay2&lt;/code&gt; graph driver, it's the containerd snapshotter. The machine is already running on the containerd image store.&lt;/p&gt;

&lt;p&gt;That difference retires a widely repeated claim: "Docker can't do lazy pulling" is no longer true. The stargz snapshotter's install documentation has a dedicated section for Docker (Moby); after a change that landed in Docker v24, writing this into &lt;code&gt;daemon.json&lt;/code&gt; and running the snapshotter service is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"features"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"containerd-snapshotter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"storage-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stargz"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the side doing lazy pulling is still the snapshotter beneath the runtime; this isn't an image format change, it's a &lt;strong&gt;runtime configuration&lt;/strong&gt; job. It's just that you can now do that configuration in Docker too.&lt;/p&gt;

&lt;h2&gt;
  
  
  eStargz: prioritised files and a TOC
&lt;/h2&gt;

&lt;p&gt;What the stargz snapshotter does is fetch the necessary chunks of the image on demand: the container runs while the download continues in the background.&lt;/p&gt;

&lt;p&gt;The format that makes this possible is eStargz. In the project's own words it's a lazily-pullable image format, and two of its properties matter. First, compatibility: it's compatible with OCI/Docker images, so it can be pushed to standard registries and still runs on eStargz-agnostic runtimes, Docker included. Second, prioritisation: the snapshotter prefetches and caches the files likely to be accessed while the container runs.&lt;/p&gt;

&lt;p&gt;Its third property — the most important one for this article — is content verification. In the eStargz documentation's words, the goal is to ensure the downloaded metadata and the contents of all files are the expected ones, based on the calculated digests. Since the layer is pulled lazily, verification happens piece by piece: on mounting a layer the snapshotter fetches the TOC from the registry, recalculates its digest and compares it against the TOC digest in the verified manifest, then re-verifies each fetched chunk against the digest in its entry. So the equation "lazy pulling means no verification" is wrong.&lt;/p&gt;

&lt;p&gt;The requirements are documented too: containerd newer than v1.4.2, a stargz snapshotter daemon on the nodes, configuration changes on the containerd side, and a tool like BuildKit v0.10+ to convert the image. On CRI-O and Podman the stargz snapshotter can't be used directly; the documentation points to a separate additional layer store (Stargz Store) for those.&lt;/p&gt;

&lt;h2&gt;
  
  
  zstd:chunked: skippable frames and range requests
&lt;/h2&gt;

&lt;p&gt;The second approach comes from the Podman/containers-storage side, and its mechanism approaches the problem differently.&lt;/p&gt;

&lt;p&gt;The classic layer format is &lt;code&gt;tar+gzip&lt;/code&gt;; the standard was later augmented with &lt;code&gt;tar+zstd&lt;/code&gt;. &lt;code&gt;zstd:chunked&lt;/code&gt; is a variant of that zstd layer: it uses zstd's &lt;strong&gt;skippable frames&lt;/strong&gt; to embed extra metadata inside the layer. The core of that metadata is a table of contents — the SHA-256 of each file plus the offsets of its chunks. On top of that, chunks are compressed separately.&lt;/p&gt;

&lt;p&gt;The result: a client can dynamically fetch only the content it doesn't already have, using HTTP range requests. It doesn't have to download the whole layer.&lt;/p&gt;

&lt;p&gt;One implementation detail matters here: each layer keeps metadata called &lt;code&gt;chunked-manifest-cache&lt;/code&gt;, and when a new image is pulled the other layers on the system are scanned through it. If a file with a matching digest is found it isn't downloaded again — depending on configuration it's shared via a hard link or a reflink, and if the filesystem doesn't support that, a full physical copy is made. Where configured, there's also a best-effort attempt to enable &lt;code&gt;fs-verity&lt;/code&gt; on the file.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;zstd:chunked&lt;/code&gt; doesn't just mean "download less", it means "don't download what you already have". Given how many libraries different images share, the saving moves from layer granularity down to file granularity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtkb2NrZXIvcG9kbWFuIHB1bGxdIC0tPiBCe0xheWVyIGZvcm1hdH0KICBCIC0tPnxjbGFzc2ljIHRhcitnemlwfCBDW1RoZSB3aG9sZSBsYXllciBpcyBkb3dubG9hZGVkPGJyLz51bnBhY2tlZCwgdGhlbiB0aGUgY29udGFpbmVyIHN0YXJ0c10KICBCIC0tPnxlU3Rhcmd6fCBEW1RoZSBUT0MgaXMgcmVhZDxici8-cHJpb3JpdGlzZWQgZmlsZXMgYXJlIHByZWZldGNoZWRdCiAgRCAtLT4gRVtUaGUgY29udGFpbmVyIHN0YXJ0czxici8-cmVtYWluaW5nIGNvbnRlbnQgYXJyaXZlcyBvbiBkZW1hbmRdCiAgQiAtLT58enN0ZDpjaHVua2VkfCBGW1RoZSB0YWJsZSBvZiBjb250ZW50cyBpcyByZWFkPGJyLz5tYXRjaGluZyBmaWxlcyBhcmUgc291Z2h0IGluIGxvY2FsIGxheWVyc10KICBGIC0tPiBHW01pc3NpbmcgY2h1bmtzIGNvbWUgdmlhIEhUVFAgcmFuZ2UgcmVxdWVzdHM8YnIvPmV4aXN0aW5nIG9uZXMgYXJlIHNoYXJlZCBieSBoYXJkbGluay9yZWZsaW5rXQogIEcgLS0-IEU%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtkb2NrZXIvcG9kbWFuIHB1bGxdIC0tPiBCe0xheWVyIGZvcm1hdH0KICBCIC0tPnxjbGFzc2ljIHRhcitnemlwfCBDW1RoZSB3aG9sZSBsYXllciBpcyBkb3dubG9hZGVkPGJyLz51bnBhY2tlZCwgdGhlbiB0aGUgY29udGFpbmVyIHN0YXJ0c10KICBCIC0tPnxlU3Rhcmd6fCBEW1RoZSBUT0MgaXMgcmVhZDxici8-cHJpb3JpdGlzZWQgZmlsZXMgYXJlIHByZWZldGNoZWRdCiAgRCAtLT4gRVtUaGUgY29udGFpbmVyIHN0YXJ0czxici8-cmVtYWluaW5nIGNvbnRlbnQgYXJyaXZlcyBvbiBkZW1hbmRdCiAgQiAtLT58enN0ZDpjaHVua2VkfCBGW1RoZSB0YWJsZSBvZiBjb250ZW50cyBpcyByZWFkPGJyLz5tYXRjaGluZyBmaWxlcyBhcmUgc291Z2h0IGluIGxvY2FsIGxheWVyc10KICBGIC0tPiBHW01pc3NpbmcgY2h1bmtzIGNvbWUgdmlhIEhUVFAgcmFuZ2UgcmVxdWVzdHM8YnIvPmV4aXN0aW5nIG9uZXMgYXJlIHNoYXJlZCBieSBoYXJkbGluay9yZWZsaW5rXQogIEcgLS0-IEU%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="779" height="792"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  SOCI: lazy loading without conversion
&lt;/h2&gt;

&lt;p&gt;There's a third approach whose design choice differs from the others. AWS's SOCI ("Seekable OCI") snapshotter aims to lazily load standard OCI images &lt;strong&gt;without requiring a build-time conversion step&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The rationale is operational: existing lazy-loading snapshotters rely on a conversion step that produces a new image artifact, which is a problem for teams that can't change their CI/CD pipeline or don't want to keep the same image in two formats. And — critically for security — the conversion step invalidates signatures created against the original OCI image.&lt;/p&gt;

&lt;p&gt;SOCI's answer is to leave the image alone and place a separate index artifact (the "SOCI index") next to it in the registry, queried at container launch through the OCI reference types mechanism.&lt;/p&gt;

&lt;p&gt;A note: the project's own documentation says this applies to the first version, that adding or removing an index on a widely deployed image can cause performance changes in production scenarios, and that the second version went back to a build-time conversion step. So "lazy loading without conversion" produced its own price in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price: which guarantee, in which stack?
&lt;/h2&gt;

&lt;p&gt;Now the main point — and here you must separate two stacks, because the same words describe different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the containerd + stargz snapshotter path&lt;/strong&gt;, "off" isn't a security setting but an installation state: without the snapshotter installed and configured, there's no lazy pulling. Content verification, as described above, is built into the format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the Podman/containers-storage path&lt;/strong&gt;, it's a configuration matter. The &lt;code&gt;enable_partial_images&lt;/code&gt; option enables partial pulls, and its default in the code is &lt;code&gt;false&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enablePartialImages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"enable_partial_images"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A little documentation archaeology is needed here: the project's &lt;code&gt;zstd:chunked&lt;/code&gt; design document says that "at the time of this writing, support for this is enabled by default in the code", and the document is dated August 2024. That sentence was true then; the default was switched on in February 2024 and switched back off in November 2024. So the documents don't contradict each other — one is stale. On your own system, check the behaviour rather than the commented-out line in &lt;code&gt;storage.conf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The genuinely interesting constraint lives in the option whose name says it all: &lt;code&gt;insecure_allow_unpredictable_image_contents&lt;/code&gt;. The documentation's first sentence is that it "should &lt;em&gt;almost never&lt;/em&gt; be set". What it does is allow partial pulls without guaranteeing that partial and non-partial pulls produce the same image contents. The side effect that rarely gets quoted is more concrete: with it enabled, image IDs become unpredictable — usually not equal to the traditional value matching the config digest.&lt;/p&gt;

&lt;p&gt;So when is the flag needed? The source code answers clearly: &lt;code&gt;zstd:chunked&lt;/code&gt; layers without &lt;code&gt;tar-split&lt;/code&gt; data fall back to the ordinary download path because consistency with non-partial pulls can't be guaranteed — unless the flag is set. eStargz layers likewise use the traditional path on the Podman side without it. Today's &lt;code&gt;tar-split&lt;/code&gt;-carrying &lt;code&gt;zstd:chunked&lt;/code&gt; images are pulled partially &lt;em&gt;with&lt;/em&gt; the guarantee intact; the flag is for the older format and for eStargz.&lt;/p&gt;

&lt;p&gt;My takeaway: what's sacrificed here isn't content verification but &lt;strong&gt;predictability&lt;/strong&gt; — the guarantee that partial and full pulls produce the same storage representation and the same image ID. It looks like a performance setting, but the consequences land on the supply chain side.&lt;/p&gt;

&lt;p&gt;If you handle image security with a CI gate like the one in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/trivy-ile-container-imaj-taramasi-cida-kirilma-esigi/" rel="noopener noreferrer"&gt;my article on container image scanning with Trivy&lt;/a&gt;, you have to ask which identity that gate scanned: is the image ID you scanned the one you run? With signatures the question is sharper still, because format conversion invalidates the signature.&lt;/p&gt;

&lt;p&gt;On the Podman side there's an official way out of this dilemma too: the &lt;code&gt;convert_images&lt;/code&gt; option converts images to &lt;code&gt;zstd:chunked&lt;/code&gt; while pulling. The documentation notes it's off by default because it's an expensive operation — but you get to choose between "forcing the old format with a flag" and "converting locally".&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does it actually pay off?
&lt;/h2&gt;

&lt;p&gt;The size of the gain depends on a single ratio: how much of the image is really read? The smaller that ratio, the bigger the difference lazy pulling makes.&lt;/p&gt;

&lt;p&gt;The places where the ratio is typically small are predictable: huge machine-learning images (CUDA layers, model weights), application images carrying a full language runtime, "just in case" debug images stuffed with tools. By contrast, a carefully built 20 MB distroless image holding a Go binary has little to gain — everything downloaded is read anyway.&lt;/p&gt;

&lt;p&gt;On the scenario side, three stand out: workloads that scale to zero (cold start latency lands directly on the user), CI runners (the same enormous image on every job), and Kubernetes clusters with many nodes (the same image pulled hundreds of times).&lt;/p&gt;

&lt;p&gt;When you do the maths, remember: lazy pulling doesn't remove the download, it &lt;strong&gt;spreads it over time&lt;/strong&gt;. When the application takes its first request, missing files may still be arriving; on a slow network the latency of those first seconds comes not from the download but from on-demand reads. Measure to the moment "it served the first request at normal latency", not "the container started".&lt;/p&gt;

&lt;p&gt;Growth on disk is a separate matter; I wrote about how layers behave on write in &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/overlayfs-copy-up-container-diskini-sessizce-sisiren-mekanizma/" rel="noopener noreferrer"&gt;the OverlayFS copy_up article&lt;/a&gt;. Lazy pulling changes the read side; the write-side mechanics stay the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Identify your stack: containerd (including Docker v24+ in &lt;code&gt;containerd-snapshotter&lt;/code&gt; mode) with a stargz snapshotter is one path, Podman/containers-storage another. On CRI-O and Podman you need a separate additional layer store (Stargz Store) rather than the stargz snapshotter.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enable_partial_images&lt;/code&gt; is off by default today; verify that from behaviour, not from the documents.&lt;/li&gt;
&lt;li&gt;Before switching on &lt;code&gt;insecure_allow_unpredictable_image_contents&lt;/code&gt;, answer this: does &lt;strong&gt;every&lt;/strong&gt; image that could ever reach this machine come from a build system you trust not to attack image integrity?&lt;/li&gt;
&lt;li&gt;Move image conversion into CI; an eStargz image built with BuildKit v0.10+ also runs on classic runtimes, so the migration can be gradual.&lt;/li&gt;
&lt;li&gt;Confirm your registry supports HTTP range requests; all three approaches rest on them.&lt;/li&gt;
&lt;li&gt;Watch the snapshotter cache: in an on-demand read setup, cache or daemon problems can surface as file read errors while the container still looks healthy. Test your readiness probe with a real file read.&lt;/li&gt;
&lt;li&gt;On the storage side, check &lt;code&gt;use_hard_links&lt;/code&gt; and reflink support; that's where file-level sharing pays.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pointing instead of copying
&lt;/h2&gt;

&lt;p&gt;Lazy pulling reminded me what container images actually are: copying a filesystem snapshot over the network. The copy model is simple and durable, but as you scale, the assumption "move everything" gets expensive.&lt;/p&gt;

&lt;p&gt;The alternative model is to point rather than copy: share a content-addressable table and fetch a chunk when it's needed. The same idea already lives in git, in registries, and in content-addressable storage; lazy pulling carries it into the runtime.&lt;/p&gt;

&lt;p&gt;So the question for your own setup: how much of the image you pull most often actually gets executed? If the answer is "less than half", these mechanisms solve your problem — as long as you hand over the guarantee knowingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/containerd/stargz-snapshotter" rel="noopener noreferrer"&gt;Stargz Snapshotter — lazy pulling for containerd&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/containers/storage/blob/main/docs/containers-storage-zstd-chunked.md" rel="noopener noreferrer"&gt;containers/storage: the zstd:chunked format and its implementation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/containers/storage/blob/main/docs/containers-storage.conf.5.md" rel="noopener noreferrer"&gt;containers-storage.conf(5) — partial pull options&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/opencontainers/image-spec/blob/main/layer.md" rel="noopener noreferrer"&gt;OCI image-spec — layer media types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/awslabs/soci-snapshotter" rel="noopener noreferrer"&gt;SOCI Snapshotter — lazy loading without conversion&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/engine/storage/containerd/" rel="noopener noreferrer"&gt;Docker: the containerd image store&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>containers</category>
      <category>oci</category>
      <category>performance</category>
    </item>
    <item>
      <title>Page Reclaim Changed Quietly: MGLRU</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Mon, 07 Sep 2026 07:36:17 +0000</pubDate>
      <link>https://dev.to/merbayerp/page-reclaim-changed-quietly-mglru-end</link>
      <guid>https://dev.to/merbayerp/page-reclaim-changed-quietly-mglru-end</guid>
      <description>&lt;p&gt;When memory starts filling up on your server, the kernel makes a decision: which page gets thrown away, which one stays? For years that decision was made with two lists — "active" and "inactive" — and most of us never argued with it. It worked well enough.&lt;/p&gt;

&lt;p&gt;That algorithm has changed. And on your server it's most likely already on.&lt;/p&gt;

&lt;p&gt;Here's the state on mine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/kernel/mm/lru_gen/enabled
&lt;span class="go"&gt;0x0007

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;CONFIG_LRU_GEN /boot/config-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;CONFIG_LRU_GEN=y
CONFIG_LRU_GEN_ENABLED=y
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;CONFIG_LRU_GEN_STATS is not &lt;span class="nb"&gt;set&lt;/span&gt;
&lt;span class="go"&gt;CONFIG_LRU_GEN_WALKS_MMU=y
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;0x0007&lt;/code&gt; means the multi-generational LRU (MGLRU) is active with all of its components. I didn't make that decision; the distribution did, it arrived with a kernel package, and no release note put it on my agenda.&lt;/p&gt;

&lt;p&gt;Don't generalise that into "it's on everywhere", though: Amazon Linux 2023's official documentation states that &lt;code&gt;CONFIG_LRU_GEN&lt;/code&gt; is compiled in but &lt;strong&gt;not enabled by default&lt;/strong&gt;, and that you enable it through the same sysfs file. So the same application may be running on two cloud servers with different reclaim algorithms. Read it rather than assume it.&lt;/p&gt;

&lt;p&gt;If it's off, there's no kernel command line parameter to make it permanent either; you need a &lt;code&gt;tmpfiles.d&lt;/code&gt; rule or a unit that writes to sysfs at boot.&lt;/p&gt;

&lt;p&gt;The most important changes in systems software are usually the quiet ones. Knowing MGLRU means &lt;strong&gt;updating your assumptions about how your server behaves under memory pressure&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old model and the new one
&lt;/h2&gt;

&lt;p&gt;In classic LRU, pages live in two lists: active and inactive. A page that gets accessed is promoted to active, an unused one falls to inactive, and under pressure pages are dropped from the tail of the inactive list. Simple and legible.&lt;/p&gt;

&lt;p&gt;MGLRU does the same job with generations. Pages sit in numbered generations ordered by access recency; younger generations hold hot pages, older ones hold cold pages. The kernel moves pages between generations through "aging" and drops them from the oldest generation through "eviction".&lt;/p&gt;

&lt;p&gt;The documentation's opening line sums up why this matters: page reclaim decides the kernel's caching policy and its ability to overcommit memory, and it directly impacts kswapd CPU usage and RAM efficiency.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgQVtOZXdseSBhY2Nlc3NlZCBwYWdlc10gLS0-IEJbWW91bmdlc3QgZ2VuZXJhdGlvbl0KICBCIC0tPiBDW0FnaW5nPGJyLz51bnRvdWNoZWQgcGFnZXM8YnIvPmRyb3AgdG8gYW4gb2xkZXIgZ2VuZXJhdGlvbl0KICBDIC0tPiBEW01pZGRsZSBnZW5lcmF0aW9uc10KICBEIC0tPiBFW09sZGVzdCBnZW5lcmF0aW9uXQogIEUgLS0-IEZ7TWVtb3J5IHByZXNzdXJlfQogIEYgLS0-fFllc3wgR1tFdmljdGlvbjxici8-ZHJvcHBlZCBmcm9tIHRoZSBvbGRlc3QgZ2VuZXJhdGlvbl0KICBGIC0tPnxOb3wgSFtQYWdlIHN0YXlzIGluIFJBTV0KICBEIC0tPnxBY2Nlc3NlZCBhZ2FpbnwgQg%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgQVtOZXdseSBhY2Nlc3NlZCBwYWdlc10gLS0-IEJbWW91bmdlc3QgZ2VuZXJhdGlvbl0KICBCIC0tPiBDW0FnaW5nPGJyLz51bnRvdWNoZWQgcGFnZXM8YnIvPmRyb3AgdG8gYW4gb2xkZXIgZ2VuZXJhdGlvbl0KICBDIC0tPiBEW01pZGRsZSBnZW5lcmF0aW9uc10KICBEIC0tPiBFW09sZGVzdCBnZW5lcmF0aW9uXQogIEUgLS0-IEZ7TWVtb3J5IHByZXNzdXJlfQogIEYgLS0-fFllc3wgR1tFdmljdGlvbjxici8-ZHJvcHBlZCBmcm9tIHRoZSBvbGRlc3QgZ2VuZXJhdGlvbl0KICBGIC0tPnxOb3wgSFtQYWdlIHN0YXlzIGluIFJBTV0KICBEIC0tPnxBY2Nlc3NlZCBhZ2FpbnwgQg%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="1844" height="222"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Three bits in the &lt;code&gt;enabled&lt;/code&gt; file
&lt;/h2&gt;

&lt;p&gt;The kill switch, &lt;code&gt;enabled&lt;/code&gt;, is a bitmask that turns three components on and off separately.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0x0001&lt;/code&gt; is the main switch: multi-gen LRU itself. &lt;code&gt;0x0002&lt;/code&gt; clears the accessed bit — the one the MMU sets — in leaf page table entries in large batches; the documentation warns this can theoretically worsen lock contention (mmap_lock), and that disabling it costs a minor performance degradation for workloads that contiguously map hot pages. &lt;code&gt;0x0004&lt;/code&gt; does the same clearing for non-leaf entries; the docs note this behaviour wasn't verified on x86 varieties other than Intel and AMD, and call the cost of disabling it negligible.&lt;/p&gt;

&lt;p&gt;Writing &lt;code&gt;y&lt;/code&gt; or &lt;code&gt;n&lt;/code&gt; applies to all components at once. The &lt;code&gt;0x0007&lt;/code&gt; in my output means all three are on.&lt;/p&gt;

&lt;p&gt;The fourth line in my output (&lt;code&gt;CONFIG_LRU_GEN_WALKS_MMU=y&lt;/code&gt;) explains why those two bits exist: it isn't a user setting but a dependency enabled automatically on architectures where the hardware sets the accessed bit itself. These are the knobs that manage the cost of walking page tables.&lt;/p&gt;

&lt;p&gt;One detail: if the hardware doesn't support a component, writing has no effect — and valid values are accepted even when the main switch is off. So don't write to the file and assume; read it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thrashing prevention: &lt;code&gt;min_ttl_ms&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;MGLRU has a little-known knob aimed directly at the "the system freezes" complaint.&lt;/p&gt;

&lt;p&gt;Write &lt;code&gt;N&lt;/code&gt; to &lt;code&gt;min_ttl_ms&lt;/code&gt; and the working set of the last &lt;code&gt;N&lt;/code&gt; milliseconds is protected from eviction. If that working set can't be kept in memory, the OOM killer fires. In the documentation's own words this works as an adjustable pressure relief valve which, when open, terminates applications that are hopefully not being used.&lt;/p&gt;

&lt;p&gt;The docs even give numbers: since the average human-detectable lag is ~100 ms, &lt;code&gt;N=1000&lt;/code&gt; usually eliminates intolerable janks; larger values like &lt;code&gt;N=3000&lt;/code&gt; make janks less noticeable at the risk of premature OOM kills. The default &lt;code&gt;0&lt;/code&gt; means disabled.&lt;/p&gt;

&lt;p&gt;It's &lt;code&gt;0&lt;/code&gt; on my server too — and that's the right place for it. This knob was added with desktop and laptop users in mind, the ones who don't have a userspace policy like &lt;code&gt;oomd&lt;/code&gt;. On the server side, the better way to manage memory pressure is a pressure-based policy like the one in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/systemd-oomd-ile-cgroup-v2-memory-pressure-runbooku/" rel="noopener noreferrer"&gt;my systemd-oomd cgroup v2 memory pressure runbook&lt;/a&gt;. MGLRU's valve doesn't replace that policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working set estimation
&lt;/h2&gt;

&lt;p&gt;MGLRU's most valuable part on a server is the debugfs interface most people never open.&lt;/p&gt;

&lt;p&gt;Reading &lt;code&gt;/sys/kernel/debug/lru_gen&lt;/code&gt; returns a histogram of the number of pages accessed over different time intervals, per memcg and node. The format is documented: the leading number is the generation number, the second column is the age in milliseconds, and the next two are the estimated anon and file pages in that generation. The lowest-numbered generation holds the coldest pages and the highest-numbered one the hottest, because the age ordering runs the other way. The histograms are noncumulative.&lt;/p&gt;

&lt;p&gt;Let me admit the first trap I fell into: looking at the &lt;code&gt;memcg 1 /&lt;/code&gt; line at the top of the file and drawing conclusions about the machine. The root cgroup comes back nearly empty, because on a systemd system almost every page is charged to a child cgroup. The meaningful table is below, from real services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A2&lt;/span&gt; &lt;span class="s2"&gt;"actions.runner&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;containerd"&lt;/span&gt; /sys/kernel/debug/lru_gen
&lt;span class="go"&gt;memcg    53 /system.slice/actions.runner.merbay-erp-kopru.kopru-vps3.service
          1   23283281          0      378128
          3   23283281      17418       65909
memcg    57 /system.slice/containerd.service
          1   23283243          0       29931
          3   23283243      73949       12960
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These values are in pages, so multiply by 4 KB. The runner service holds ~378k file pages in its cold generation — roughly 1.5 GB of page cache — while its hot generation holds ~65k file and ~17k anon pages. On the containerd side the table inverts: ~74k anon pages in the hot generation, about 290 MB of genuinely working memory.&lt;/p&gt;

&lt;p&gt;The second trap is subtler and visible in my own output: the age values within each memcg are identical. The kernel source explains why — when an lruvec is first initialised, all generation timestamps are set to the same moment. So aging hasn't advanced at all on this machine; the bins aren't "different time intervals", they only show the current distribution. A real working set measurement needs aging to be triggered first.&lt;/p&gt;

&lt;p&gt;You can also trigger aging by hand, which is the cure for the gap above: the command &lt;code&gt;+ memcg_id node_id max_gen_nr [can_swap [force_scan]]&lt;/code&gt; creates a new generation. A scheduler can run it at intervals and rank its servers by how many cold pages that interval reveals. If you're measuring, the flow is: create a generation, wait, read the histogram — a single read doesn't give you a working set.&lt;/p&gt;

&lt;p&gt;The number of generations is fixed too: the kernel keeps at most four, and the two youngest count as "not fully aged". The documentation maps those two onto the classic LRU's active list, so there's a direct equivalent for anyone who knows the old model.&lt;/p&gt;

&lt;p&gt;The same interface can do proactive reclaim. This command evicts generations less than or equal to the given generation number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- memcg_id node_id min_gen_nr [swappiness [nr_to_reclaim]]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The details matter: &lt;code&gt;min_gen_nr&lt;/code&gt; must be less than &lt;code&gt;max_gen_nr-1&lt;/code&gt;, because the two youngest generations aren't fully aged and therefore can't be evicted. Here &lt;code&gt;swappiness&lt;/code&gt; overrides the value in &lt;code&gt;/proc/sys/vm/swappiness&lt;/code&gt;, with a valid range of 0-200 plus &lt;code&gt;max&lt;/code&gt;, which is used exclusively for reclaiming anonymous memory. &lt;code&gt;nr_to_reclaim&lt;/code&gt; caps how many pages get evicted.&lt;/p&gt;

&lt;p&gt;This is the official way to say "clear cold pages while there's no memory pressure" — tailor-made for a scheduler preparing a server before placing a new job, and, as the docs stress, with limited impact on the jobs already running.&lt;/p&gt;

&lt;p&gt;A note: this interface sits under "experimental features" and needs root access to debugfs. If you're going to wire production automation to it, do so accepting that behaviour may change across kernel upgrades.&lt;/p&gt;

&lt;p&gt;If all you want is proactive reclaim, there's already a stable, non-experimental interface: cgroup v2's &lt;code&gt;memory.reclaim&lt;/code&gt;. It triggers reclaim in the target cgroup and accepts a &lt;code&gt;swappiness&lt;/code&gt; nested key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"1G"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/fs/cgroup/system.slice/backup.service/memory.reclaim
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the two caveats from the docs: the kernel can over- or under-reclaim (under-reclaim returns &lt;code&gt;-EAGAIN&lt;/code&gt;), and reclaim triggered this way is not meant to indicate memory pressure — so the networking layer's socket memory balancing isn't exercised. For cold-page cleanup in production, start here; MGLRU's debugfs interface is for &lt;em&gt;measuring&lt;/em&gt; the working set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working set per container
&lt;/h2&gt;

&lt;p&gt;Note the first line of that output: &lt;code&gt;memcg 1 /&lt;/code&gt;. The histogram is per cgroup, meaning every container on the machine gets its own table. That makes it possible to separate two questions people routinely conflate in capacity planning.&lt;/p&gt;

&lt;p&gt;First question: "how much memory has this container &lt;em&gt;allocated&lt;/em&gt;?" &lt;code&gt;memory.current&lt;/code&gt; answers it, and it usually looks larger than it should, because page cache counts there too. Second question: "how many pages did this container actually &lt;em&gt;touch&lt;/em&gt; in the last N milliseconds?" Only the generation histogram answers that.&lt;/p&gt;

&lt;p&gt;When we set limits we look at the first number and try to guess the second; MGLRU makes the second directly measurable. On a dense container host, revisiting &lt;code&gt;memory.max&lt;/code&gt; values against this table is far cheaper than the habit of "give it twice as much, just in case".&lt;/p&gt;

&lt;p&gt;One caveat: the table is a snapshot and aging runs continuously. Don't decide from a single read; look during both busy and quiet hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where MGLRU stands in 2026
&lt;/h2&gt;

&lt;p&gt;Ending this with "it settled in quietly, case closed" would be wrong, because the upstream discussion is open.&lt;/p&gt;

&lt;p&gt;MGLRU was merged in 6.1, but progress stalled and it still isn't enabled on many systems. In the 2026 memory-management discussions, some developers looked for ways to improve it while another argued for removing it entirely — the objection being less technical than about maintenance: that the feature wasn't owned and that the assigned maintainers weren't contributing. In the same period the LSFMM+BPF summit devoted three separate sessions to it, covering both better integration and problems seen on Android.&lt;/p&gt;

&lt;p&gt;The operational translation: classic LRU wasn't removed, MGLRU didn't become mandatory, both implementations sit side by side, and distributions have landed on different sides. This isn't a "migration to the new standard" story; it's still a choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what did I gain?
&lt;/h2&gt;

&lt;p&gt;The honest answer: nothing measurable changed on my own server, because there's no memory pressure there. While writing this, the machine was using 11 GB of its 96 GB and the averages in &lt;code&gt;/proc/pressure/memory&lt;/code&gt; were zero.&lt;/p&gt;

&lt;p&gt;MGLRU's difference shows up under pressure. The generation-based model aims to make scanning cheaper and to protect hot pages better; on paper the gain lands in the CPU kswapd burns and in the amount of work you can carry on the same RAM.&lt;/p&gt;

&lt;p&gt;It isn't free, though: aging walks page tables, and the two bits in the &lt;code&gt;enabled&lt;/code&gt; mask exist precisely to manage that cost. The criticism that it's expensive on low-end hardware comes from the same place.&lt;/p&gt;

&lt;p&gt;But be careful here: I can't tell you "it got X% faster", because I didn't measure it. If you want to, the right method is to put your workload under pressure and compare kswapd CPU and application latency while flipping &lt;code&gt;enabled&lt;/code&gt; between &lt;code&gt;y&lt;/code&gt; and &lt;code&gt;n&lt;/code&gt;. On one machine, with one workload.&lt;/p&gt;

&lt;p&gt;If you use compressed swap, the two work together: MGLRU decides which page gets evicted, and &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/sikistirilmis-swap-zram-mi-zswap-mi/" rel="noopener noreferrer"&gt;zram or zswap&lt;/a&gt; decides where the evicted page goes. One makes the selection, the other handles the storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cat /sys/kernel/mm/lru_gen/enabled&lt;/code&gt; → is it on, and which components are active?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep CONFIG_LRU_GEN /boot/config-$(uname -r)&lt;/code&gt; → what's your distribution's default, is &lt;code&gt;CONFIG_LRU_GEN_ENABLED&lt;/code&gt; set?&lt;/li&gt;
&lt;li&gt;When you hit trouble, turn the main switch off and compare: &lt;code&gt;echo n &amp;gt; /sys/kernel/mm/lru_gen/enabled&lt;/code&gt;. Reversible, but not free: during the switch the kernel moves pages across every cgroup between two list layouts. On a large-memory machine with hundreds of cgroups, do it in a maintenance window rather than under pressure.&lt;/li&gt;
&lt;li&gt;On desktop/laptop setups with jank complaints, try &lt;code&gt;min_ttl_ms&lt;/code&gt; starting at 1000; on servers, set up a pressure-based policy first.&lt;/li&gt;
&lt;li&gt;If you're curious about working sets, look at the debugfs histogram — but remember the "experimental" label before wiring it into production automation.&lt;/li&gt;
&lt;li&gt;Additional statistics live in &lt;code&gt;/sys/kernel/debug/lru_gen_full&lt;/code&gt;; &lt;code&gt;CONFIG_LRU_GEN_STATS&lt;/code&gt; only keeps historical stats from evicted generations there, at a memory cost. On a kernel like mine, where it's off, that history isn't available.&lt;/li&gt;
&lt;li&gt;When measuring, look at &lt;code&gt;pgscan_kswapd&lt;/code&gt;, &lt;code&gt;pgsteal_kswapd&lt;/code&gt; and &lt;code&gt;workingset_refault_*&lt;/code&gt; in &lt;code&gt;/proc/vmstat&lt;/code&gt; — but account for these metrics behaving differently under MGLRU than under classic LRU; your dashboards may not be calibrated for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Defaults change quietly
&lt;/h2&gt;

&lt;p&gt;More than a technical topic, MGLRU reminded me of a habit: the things we trust most in infrastructure are the things we question least. Page reclaim sat inside the "the kernel handles it" box for years; one day the algorithm changed, and so did the contents of the box.&lt;/p&gt;

&lt;p&gt;I'm not saying that's bad — quite the opposite, distributions manage these transitions well. But knowing when the mental model you use to explain a server's behaviour was last updated makes a difference during diagnosis. When you say "under memory pressure it behaves like this", you should know which kernel's algorithm you're talking about.&lt;/p&gt;

&lt;p&gt;So the question for your own setup is simple: which of your kernel's defaults changed in the past year, and how many of them did you notice?&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/admin-guide/mm/multigen_lru.html" rel="noopener noreferrer"&gt;Linux kernel: Multi-Gen LRU admin guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/master/Documentation/admin-guide/mm/multigen_lru.rst" rel="noopener noreferrer"&gt;The source document in the kernel tree (multigen_lru.rst)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/admin-guide/sysctl/vm.html" rel="noopener noreferrer"&gt;Linux kernel: virtual memory sysctl reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>kernel</category>
      <category>memory</category>
      <category>performance</category>
    </item>
    <item>
      <title>Setting Up Immich: The Operational Cost of Google Photos Convenience</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 06 Sep 2026 09:34:52 +0000</pubDate>
      <link>https://dev.to/merbayerp/setting-up-immich-the-operational-cost-of-google-photos-convenience-59bk</link>
      <guid>https://dev.to/merbayerp/setting-up-immich-the-operational-cost-of-google-photos-convenience-59bk</guid>
      <description>&lt;h2&gt;
  
  
  What Is Immich and What Are Its System Requirements?
&lt;/h2&gt;

&lt;p&gt;Immich is a self-hosted photo and video management platform that runs components such as PostgreSQL, Redis, and Nginx inside Docker containers. For a baseline setup, a minimum of &lt;strong&gt;4 CPU cores&lt;/strong&gt;, &lt;strong&gt;8 GiB RAM&lt;/strong&gt;, and a &lt;strong&gt;100 GiB&lt;/strong&gt; SSD is recommended. The following command verifies the kernel version on Ubuntu 22.04 LTS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;5.15.0-1049-azure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This output confirms that the Linux kernel belongs to the 5.15 branch and smoothly supports Docker's kernel-level features (cgroups v2, overlay2). Once these prerequisites are met, running Immich's components isolated within separate containers provides significant advantages in terms of both security and scalability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment with Docker Compose (Sample Scenario)
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;docker-compose.yml&lt;/code&gt; file below pulls official images from Docker Hub. The configuration includes PostgreSQL 15, Redis 7, and Nginx-proxy-manager 2.10.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.9"&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/immich-app/immich-server:latest&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB_HOST=postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REDIS_HOST=redis&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3001:3001"&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
  &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:15-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="s"&gt;POSTGRES_PASSWORD=immich&lt;/span&gt;
      &lt;span class="s"&gt;POSTGRES_DB=immich&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verifying container status after startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Service}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.State}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;SERVICE   STATE
server    running
redis     running
postgres  running
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing all services in the &lt;strong&gt;running&lt;/strong&gt; state confirms that core dependencies have booted properly. The &lt;code&gt;docker compose logs -f server&lt;/code&gt; command generates an entry confirming a successful database connection during initial boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2024-10-12 08:15:23.123 INFO  [server] Connected to PostgreSQL at postgres:5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Storage and Network Cost Analysis
&lt;/h2&gt;

&lt;p&gt;Immich stores media files directly on an attached disk (e.g., &lt;code&gt;/mnt/immich-data&lt;/code&gt;). To inspect disk usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /mnt/immich-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1       200G   45G  145G  24% /mnt/immich-data
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This output shows 24% utilization, running 45 GiB of photo/video data on a 200 GiB SSD. Network throughput can be monitored with &lt;code&gt;iftop&lt;/code&gt;; here is a sample one-minute capture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total send rate: 12.3 Mb/s   Total receive rate: 9.8 Mb/s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These numbers indicate ample bandwidth for an average family's photo-streaming workload. Google Photos offers a free tier with a fixed storage quota; Immich's SSD expense, on the other hand, depends on the regional pricing model of your chosen infrastructure provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Monitoring and Defining SLOs
&lt;/h2&gt;

&lt;p&gt;Immich collects metrics using a Prometheus exporter. Running &lt;code&gt;docker compose exec server curl -s http://localhost:3001/metrics | grep http_requests_total&lt;/code&gt; reveals the total count of HTTP requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;http_requests_total&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/api/assets"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;124578&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This volume points to an average of roughly &lt;strong&gt;124k&lt;/strong&gt; requests per day, highlighting that our SLO should aim to keep &lt;strong&gt;99.9%&lt;/strong&gt; of request response times under &lt;strong&gt;200 ms&lt;/strong&gt;. A Grafana dashboard can be attached for real-time observability; for instance, auto-scaling could be triggered if CPU consumption exceeds 65%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback and Update Strategy
&lt;/h2&gt;

&lt;p&gt;Upgrading Immich versions is handled by updating the &lt;code&gt;image&lt;/code&gt; tag in your Docker Compose file. Always take a fresh database dump before updating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; immich &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; immich_backup_&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command stamps the backup file with today's date. If an issue surfaces following the upgrade, rolling back is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose down
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; immich &amp;lt; immich_backup_&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rollback sequence restores the previous release without data loss. Command execution order is critical here: stop the database service first, bring up the targeted version, and restore the snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Comparison with Google Photos
&lt;/h2&gt;

&lt;p&gt;Beyond its initial 15 GB free allowance, Google Photos offers a 100 GB tier at &lt;strong&gt;$1.99/month&lt;/strong&gt;. Hosting an equivalent capacity on an SSD-backed VPS running Immich—for example, a &lt;strong&gt;2 vCPU, 8 GiB RAM&lt;/strong&gt; instance—runs around &lt;strong&gt;$12&lt;/strong&gt; (Azure B2s) + &lt;strong&gt;$20&lt;/strong&gt; (200 GiB SSD) = &lt;strong&gt;$32/month&lt;/strong&gt;. The trade-off is clear: Immich delivers total data privacy and full customization, whereas Google Photos involves corporate data processing and potential ad-targeting implications.&lt;/p&gt;

&lt;p&gt;The Mermaid diagram below illustrates the architectural differences between the two approaches:&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Backup Strategy
&lt;/h2&gt;

&lt;p&gt;A disciplined backup regimen is essential for data integrity and continuity. The first line of defense is dumping the PostgreSQL database on a daily or weekly schedule using &lt;code&gt;pg_dump&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; immich &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /backup/immich_&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For media files, archiving them with &lt;code&gt;tar&lt;/code&gt; and shipping them off to secondary storage is recommended:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/immich_media_&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz /mnt/immich-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transferring these backups to a secure remote destination (such as AWS S3, Azure Blob Storage, or another off-site cloud store) shields your data from local hardware failures. You can automate this process by adding an entry to &lt;code&gt;crontab&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 2 * * * /usr/local/bin/immich-backup.sh &amp;gt;&amp;gt; /var/log/immich/backup.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Testing your restore pipeline is the single most reliable way to prevent catastrophic data loss. Restoring the database backup looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; immich &amp;lt; /backup/immich_2024-09-06.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shape your backup lifecycle around your recovery time and recovery point objectives: daily backups for critical assets, weekly snapshots for less dynamic content. Pruning or archiving historical snapshots at the end of every backup cycle keeps long-term storage bills predictable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security and Compliance Controls
&lt;/h2&gt;

&lt;p&gt;Shielding your self-hosted setup from external threats is vital for both privacy and regulatory compliance. The first step is enforcing end-to-end TLS encryption across all inbound traffic. You can issue a free Let's Encrypt certificate using &lt;code&gt;certbot&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;certbot certonly &lt;span class="nt"&gt;--webroot&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /var/www/html &lt;span class="nt"&gt;-d&lt;/span&gt; immich.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure the &lt;code&gt;nginx&lt;/code&gt; reverse proxy in your Docker Compose file to consume this certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:alpine&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/letsencrypt:/etc/letsencrypt:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/www/html:/usr/share/nginx/html&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In addition, isolate inter-container communication so services only touch networks on necessary ports. For example, the &lt;code&gt;server&lt;/code&gt; container only needs access to &lt;code&gt;postgres&lt;/code&gt; and &lt;code&gt;redis&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;immich_net&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;redis&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For identity management, integrating OAuth2 or LDAP allows you to manage user access through a centralized provider. To protect data at rest, the underlying storage volume holding your media assets can be secured with a LUKS encryption layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cryptsetup luksFormat /dev/sda1
cryptsetup open /dev/sda1 immich-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Comprehensive audit trails and system monitoring should also be configured. Tools like &lt;code&gt;auditd&lt;/code&gt; help trace unauthorized system calls and catch modifications to sensitive configuration files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational Cost Breakdown
&lt;/h2&gt;

&lt;p&gt;A detailed breakdown of ongoing operational costs comes down to three pillars: compute resources, storage capacity, and bandwidth egress. In a standard VPS environment, monthly compute expenses for a 2 vCPU / 8 GiB RAM box are determined by your provider's hourly rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Compute Cost = (Hourly Rate) × 24 × 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storage expenses track directly with the volume of allocated SSD or HDD capacity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Storage Cost = (GB per Month) × (Price per GB per Month)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data transfer costs are billed based on outbound egress traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bandwidth Cost = (GB Outbound) × (Price per GB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Secondary storage fees for backups will reflect the storage tier you pick. Storing a 200 GiB compressed backup snapshot each month scales linearly with that provider's per-gigabyte pricing. Tallying up each line item gives you the actual monthly operating bill.&lt;/p&gt;

&lt;p&gt;Several tactics can help optimize these numbers: commit to reserved instances or leverage spot capacity for predictable workloads to shrink compute overhead. Auto-scaling lets your setup expand during heavy sync periods and dial back during quiet hours. Finally, placing a CDN in front of assets and enabling media compression will trim outbound network bills while boosting load times for end users. Combining these approaches keeps Immich financially viable and performant over the long haul.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Spinning up Immich via Docker Compose delivers a polished, open-source alternative for managing personal media libraries, though infrastructure and storage costs will easily outpace Google Photos' entry-level pricing. Establishing active monitoring, setting strict SLOs, and defining safe rollback paths are what keep operational overhead manageable over time. If your primary driver is rock-bottom price, Google Photos wins on convenience. If total data sovereignty, uncompromised privacy, and flexibility take precedence, hosting Immich on your own infrastructure is the way to go. A logical next step: wire up a production &lt;strong&gt;Prometheus-Grafana&lt;/strong&gt; stack to generate real-time performance metrics and cost projections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://support.google.com/photos/?hl=en" rel="noopener noreferrer"&gt;Google Photos Help&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://play.google.com/store/apps/details?id=app.alextran.immich&amp;amp;hl=cs" rel="noopener noreferrer"&gt;Immich – Aplikace na Google Play&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://chromewebstore.google.com/detail/google-photos-delete-tool/jiahfbbfpacpolomdjlpdpiljllcdenb?ucbcb=1" rel="noopener noreferrer"&gt;Google Photos Delete Tool - Chrome Web Store&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/immich-app/immich" rel="noopener noreferrer"&gt;GitHub - immich-app/immich: High performance self-hosted photo and...&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.cloud.google.com/java/docs/reference" rel="noopener noreferrer"&gt;Java Cloud Client Libraries | Google Cloud Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://wiki.archlinux.org/title/Immich" rel="noopener noreferrer"&gt;Immich - ArchWiki&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.google.com/?hl=mi" rel="noopener noreferrer"&gt;Google&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.google.com/photos/thread/402679593/redmagic-11-pro-watermark?hl=en" rel="noopener noreferrer"&gt;RedMagic 11 Pro Watermark - Google Photos Community&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>guide</category>
      <category>software</category>
    </item>
    <item>
      <title>fanotify: Watching File Access — and Stopping It</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 06 Sep 2026 09:32:49 +0000</pubDate>
      <link>https://dev.to/merbayerp/fanotify-watching-file-access-and-stopping-it-4cbi</link>
      <guid>https://dev.to/merbayerp/fanotify-watching-file-access-and-stopping-it-4cbi</guid>
      <description>&lt;p&gt;The classic Linux answer to "what's changing in that directory?" is inotify. It works beautifully on small trees; the problem is that watching is set up per directory. When you want to watch a storage area with hundreds of thousands of directories, you need a watch for each one — and at some point you hit the limit.&lt;/p&gt;

&lt;p&gt;Here's the limit on my own server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sysctl fs.inotify.max_user_watches fs.inotify.max_user_instances
&lt;span class="go"&gt;fs.inotify.max_user_watches = 753024
fs.inotify.max_user_instances = 128
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven hundred thousand sounds like plenty, but not for someone running a file-sharing server — and long before you reach that number, walking the tree to mark every directory becomes a job that takes minutes on its own.&lt;/p&gt;

&lt;p&gt;fanotify asks a different question: instead of marking directories one by one, what if we watched an entire mount point or filesystem? And it does one more thing — it can not only &lt;em&gt;see&lt;/em&gt; access but &lt;em&gt;stop&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;The thesis of this article: what makes fanotify interesting isn't the scale, it's that second capability. And that capability carries a serious responsibility: if your listener doesn't answer, the process trying to open the file waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two APIs, two mindsets
&lt;/h2&gt;

&lt;p&gt;With inotify the model is simple: you give a path, you get events about that path. With fanotify a mark has three scopes, and the choice shapes your architecture: individual files and directories, a mount point with &lt;code&gt;FAN_MARK_MOUNT&lt;/code&gt;, or an entire filesystem with &lt;code&gt;FAN_MARK_FILESYSTEM&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The privilege side deserves precision, because most write-ups are sloppy here. fanotify isn't entirely a root affair: since 5.13 the &lt;code&gt;FAN_CLASS_NOTIF&lt;/code&gt; class can be opened by an unprivileged user, provided one of the file-handle reporting flags is set. The comment in the kernel source explains why — unprivileged groups don't get file descriptors in events, so handle reporting is mandatory.&lt;/p&gt;

&lt;p&gt;But here's the crux: &lt;strong&gt;the wide-scope marking that sells this API isn't available unprivileged.&lt;/strong&gt; Both &lt;code&gt;FAN_MARK_MOUNT&lt;/code&gt; and &lt;code&gt;FAN_MARK_FILESYSTEM&lt;/code&gt; require &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt;; an unprivileged group can only place individual inode marks, sees no pid or file descriptor in events, and can't use the unlimited-queue or unlimited-marks flags. So "one mark covers the whole disk" isn't free — it comes with root.&lt;/p&gt;

&lt;p&gt;That scope difference is the difference between "I'll watch 10 directories" and "I'll see what happens on this disk". The second is theoretically possible with inotify but means walking the tree and opening hundreds of thousands of watches; with fanotify it's a single call.&lt;/p&gt;

&lt;p&gt;The resource limits became tunable in 5.13. The values on my server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sysctl fs.fanotify.max_queued_events fs.fanotify.max_user_groups fs.fanotify.max_user_marks
&lt;span class="go"&gt;fs.fanotify.max_queued_events = 16384
fs.fanotify.max_user_groups = 128
fs.fanotify.max_user_marks = 800683
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These three cap the number of queued events, the number of groups per user, and the number of marks per user respectively. Before 5.13 they were hardcoded constants: 16384 events, 128 groups per user, and 8192 marks per group.&lt;/p&gt;

&lt;p&gt;You may have noticed something: fanotify's mark limit (800683) is only 6% above inotify's watch limit (753024). The gain isn't "more marks you can place"; the gain is that a single mark covers an entire tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real difference: the power to permit
&lt;/h2&gt;

&lt;p&gt;When you initialise an fanotify group you choose a "notification class", and that choice determines what you can do.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FAN_CLASS_NOTIF&lt;/code&gt; is the default: you're told that a file was accessed, but you can't decide before the access happens. That's the class for accounting and auditing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FAN_CLASS_CONTENT&lt;/code&gt; and &lt;code&gt;FAN_CLASS_PRE_CONTENT&lt;/code&gt; unlock permission events. The man page even names the use cases: the first is for listeners that need to access files once they already contain their final content — malware detection programs are the example given; the second is for listeners that may need to write data before the final data can be accessed — hierarchical storage managers are the example. The two permission classes require &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt;; &lt;code&gt;FAN_CLASS_NOTIF&lt;/code&gt; can be opened unprivileged under the conditions described above.&lt;/p&gt;

&lt;p&gt;With a permission class you receive &lt;code&gt;FAN_OPEN_PERM&lt;/code&gt;, &lt;code&gt;FAN_ACCESS_PERM&lt;/code&gt; and &lt;code&gt;FAN_OPEN_EXEC_PERM&lt;/code&gt; (open with intent to execute) events, and for each one you &lt;strong&gt;must write a response&lt;/strong&gt;: &lt;code&gt;FAN_ALLOW&lt;/code&gt; grants, &lt;code&gt;FAN_DENY&lt;/code&gt; refuses. A denied call receives &lt;code&gt;EPERM&lt;/code&gt;. Since Linux 6.13, groups initialised with &lt;code&gt;FAN_CLASS_PRE_CONTENT&lt;/code&gt; can deny with a different error using &lt;code&gt;FAN_DENY_ERRNO()&lt;/code&gt; — you can make the application see &lt;code&gt;EIO&lt;/code&gt;, for instance. It isn't an arbitrary error code though; the man page lists the permitted set: &lt;code&gt;EPERM&lt;/code&gt;, &lt;code&gt;EIO&lt;/code&gt;, &lt;code&gt;EBUSY&lt;/code&gt;, &lt;code&gt;ETXTBSY&lt;/code&gt;, &lt;code&gt;EAGAIN&lt;/code&gt;, &lt;code&gt;ENOSPC&lt;/code&gt;, &lt;code&gt;EDQUOT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtBIHByb2Nlc3MgdHJpZXMgdG8gb3BlbiBhIGZpbGVdIC0tPiBCe1doaWNoIGNsYXNzIGlzIHRoZSBsaXN0ZW5lcj99CiAgQiAtLT58RkFOX0NMQVNTX05PVElGfCBDW0V2ZW50IGlzIHF1ZXVlZDxici8-dGhlIG9wZW4gZG9lc24ndCB3YWl0XQogIEIgLS0-fENPTlRFTlQgLyBQUkVfQ09OVEVOVHwgRFtUaGUgb3BlbiBpcyBIRUxEPGJyLz5hIHBlcm1pc3Npb24gZXZlbnQgaXMgcXVldWVkXQogIEQgLS0-IEV7RGlkIHRoZSBsaXN0ZW5lciByZXNwb25kP30KICBFIC0tPnxGQU5fQUxMT1d8IEZbVGhlIG9wZW4gcHJvY2VlZHNdCiAgRSAtLT58RkFOX0RFTll8IEdbVGhlIGNhbGwgZ2V0cyBFUEVSTTxici8-Ni4xMysgYWxsb3dzIG90aGVyIGVycm5vc10KICBFIC0tPnxMaXN0ZW5lciBodW5nfCBIW1RoZSBwcm9jZXNzIHdhaXRzPGJyLz50aGUgd2FpdGluZyB0YXNrIGlzIGtpbGxhYmxlXQogIEUgLS0-fExpc3RlbmVyIGRpZWQsIGZkIGNsb3NlZHwgSVtQZW5kaW5nIHBlcm1pc3Npb24gZXZlbnRzPGJyLz5iZWNvbWUgRkFOX0FMTE9XOiBmYWlsLW9wZW5d%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtBIHByb2Nlc3MgdHJpZXMgdG8gb3BlbiBhIGZpbGVdIC0tPiBCe1doaWNoIGNsYXNzIGlzIHRoZSBsaXN0ZW5lcj99CiAgQiAtLT58RkFOX0NMQVNTX05PVElGfCBDW0V2ZW50IGlzIHF1ZXVlZDxici8-dGhlIG9wZW4gZG9lc24ndCB3YWl0XQogIEIgLS0-fENPTlRFTlQgLyBQUkVfQ09OVEVOVHwgRFtUaGUgb3BlbiBpcyBIRUxEPGJyLz5hIHBlcm1pc3Npb24gZXZlbnQgaXMgcXVldWVkXQogIEQgLS0-IEV7RGlkIHRoZSBsaXN0ZW5lciByZXNwb25kP30KICBFIC0tPnxGQU5fQUxMT1d8IEZbVGhlIG9wZW4gcHJvY2VlZHNdCiAgRSAtLT58RkFOX0RFTll8IEdbVGhlIGNhbGwgZ2V0cyBFUEVSTTxici8-Ni4xMysgYWxsb3dzIG90aGVyIGVycm5vc10KICBFIC0tPnxMaXN0ZW5lciBodW5nfCBIW1RoZSBwcm9jZXNzIHdhaXRzPGJyLz50aGUgd2FpdGluZyB0YXNrIGlzIGtpbGxhYmxlXQogIEUgLS0-fExpc3RlbmVyIGRpZWQsIGZkIGNsb3NlZHwgSVtQZW5kaW5nIHBlcm1pc3Npb24gZXZlbnRzPGJyLz5iZWNvbWUgRkFOX0FMTE9XOiBmYWlsLW9wZW5d%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="1104" height="1049"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The last two boxes are what anyone considering fanotify in production needs to digest first — and this is where intuition fails.&lt;/p&gt;

&lt;p&gt;If your listener is &lt;strong&gt;alive but not responding&lt;/strong&gt; (a hung thread, an unread queue), processes trying to reach the file wait; those waiting tasks are killable, so &lt;code&gt;SIGKILL&lt;/code&gt; gets you out. But if the listener &lt;strong&gt;dies&lt;/strong&gt;, the picture inverts: the man page is explicit — upon close, outstanding permission events are set to allowed. The kernel source does the same thing; as the group is released, every pending permission event is finished with &lt;code&gt;FAN_ALLOW&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the real risk isn't a wedged system, it's &lt;strong&gt;your security gate opening silently&lt;/strong&gt;. When a listener doing malware scanning crashes, everything looks normal; there's simply nobody at the door any more. There's an interesting asymmetry too: when memory can't be allocated the kernel denies the permission event, failing closed in that case.&lt;/p&gt;

&lt;p&gt;Linux 6.18 shed a little light on this blind spot: with &lt;code&gt;fs.fanotify.watchdog_timeout&lt;/code&gt;, if a permission event goes unanswered for the configured period the kernel logs a warning naming the PID that failed to respond to the queue. It doesn't resolve the event, but it does make "the doorkeeper fell asleep" visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lost events and an overflowing queue
&lt;/h2&gt;

&lt;p&gt;The most dangerous state for a monitoring system is silently missing events while believing it sees everything. fanotify is honest here: when the queue limit is exceeded a &lt;code&gt;FAN_Q_OVERFLOW&lt;/code&gt; event is generated. There's one exception, and it concerns exactly the permission class: overflow events aren't queued for permission events — there, access is denied outright. The &lt;code&gt;FAN_UNLIMITED_QUEUE&lt;/code&gt; flag removes the limit — and again requires &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more detail: events can be merged. Consecutive events for the same filesystem object originating from the same process may be merged into a single event. The one exception is permission events — two of those are never merged into one queue entry.&lt;/p&gt;

&lt;p&gt;The analytical consequence is clear: fanotify doesn't give you a "how many times was it read" counter, it gives you "it was read". If you're counting, account for merging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing between descriptor and handle
&lt;/h2&gt;

&lt;p&gt;In classic usage each event carries an open file descriptor, and &lt;strong&gt;closing it is the reading application's responsibility&lt;/strong&gt;. In a high-volume system that's the easiest path to descriptor leaks.&lt;/p&gt;

&lt;p&gt;The alternative is initialising the group with flags like &lt;code&gt;FAN_REPORT_FID&lt;/code&gt; or &lt;code&gt;FAN_REPORT_DIR_FID&lt;/code&gt;. But know this first: the trade-off only exists in the notification class. The kernel rejects fid mode together with any class other than &lt;code&gt;FAN_CLASS_NOTIF&lt;/code&gt; with &lt;code&gt;EINVAL&lt;/code&gt;. In a permission class there is no choice — every event carries a descriptor and you must close it. For anyone building a permission gate, descriptor-leak risk isn't a preference, it's a given. Objects are then identified by file handles instead of descriptors, and a separate information record arrives with the event. These flags stack; with &lt;code&gt;FAN_REPORT_TARGET_FID&lt;/code&gt; and &lt;code&gt;FAN_REPORT_PIDFD&lt;/code&gt; together, one event can carry two fid records and a pidfd record. The man page's warning matters: with such a stacked configuration there is no guarantee about the ordering of information records.&lt;/p&gt;

&lt;p&gt;If you want to turn a handle into a path, &lt;code&gt;open_by_handle_at()&lt;/code&gt; comes into play — the answer to "which file was it" isn't free, it's an extra call. In exchange you don't open and close a descriptor for every event; at high volume that trade usually favours handles.&lt;/p&gt;

&lt;p&gt;In practice: use a descriptor if you're going to read the file's contents, and a handle if you only need to know which object it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things it doesn't see
&lt;/h2&gt;

&lt;p&gt;The man page's "limitations and caveats" section is short but it shapes production decisions.&lt;/p&gt;

&lt;p&gt;fanotify reports only events that a user-space program triggers through the filesystem API, so it doesn't catch remote events on network filesystems. A change made by another client on a directory you share over NFS never reaches your listener.&lt;/p&gt;

&lt;p&gt;Accesses and modifications happening through &lt;code&gt;mmap()&lt;/code&gt;, &lt;code&gt;msync()&lt;/code&gt; and &lt;code&gt;munmap()&lt;/code&gt; are not reported either. If you're watching an application that works with memory-mapped files, the picture you see is incomplete.&lt;/p&gt;

&lt;p&gt;Events for directories are created only if the directory itself is opened, read and closed; adding, removing or changing children of a marked directory doesn't create such an event. To see changes in directory contents you have to request separate event types like &lt;code&gt;FAN_CREATE&lt;/code&gt; and &lt;code&gt;FAN_DELETE&lt;/code&gt;. There's a scope trap here as well: these handle-identified events can't be supplied in the mask together with &lt;code&gt;FAN_MARK_MOUNT&lt;/code&gt; — the attempt returns &lt;code&gt;EINVAL&lt;/code&gt;. So the plan of "mark the mount point and watch what gets created on the file server" doesn't work directly.&lt;/p&gt;

&lt;p&gt;There's also a security note that bears directly on the permission class: the kernel doesn't check whether the receiving process is authorised to read or write the file before passing it a descriptor. When &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt; is granted to programs run by unprivileged users, that's a risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locking yourself out
&lt;/h2&gt;

&lt;p&gt;There's a trap every listener author in a permission class eventually hits, and it's easy to miss in the man page: the descriptor delivered with the event has &lt;code&gt;FMODE_NONOTIFY&lt;/code&gt; set, so access &lt;strong&gt;through that descriptor&lt;/strong&gt; generates no new events.&lt;/p&gt;

&lt;p&gt;But if your listener reopens the file by path — while writing a log, copying to quarantine, or resolving a handle with &lt;code&gt;open_by_handle_at()&lt;/code&gt; — it triggers its own permission event and blocks itself. That's the classic fatal bug in fanotify-based scanners. The rule is simple: while making the decision, use only the descriptor you were given, and keep your own &lt;code&gt;open()&lt;/code&gt; calls away from the path you're gating.&lt;/p&gt;

&lt;p&gt;A second detail: marks aren't retroactive. Access through descriptors opened before your listener started is invisible to you. Service ordering is therefore a security matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state on my server
&lt;/h2&gt;

&lt;p&gt;Two kernel options decide, and both are on for me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;CONFIG_FANOTIFY /boot/config-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;CONFIG_FANOTIFY=y
CONFIG_FANOTIFY_ACCESS_PERMISSIONS=y
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first enables the API, the second the permission handling. On a kernel with the second one off, trying to open a &lt;code&gt;FAN_CLASS_CONTENT&lt;/code&gt; group goes nowhere — don't start a permission-based design before verifying this on your distribution's kernel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in recent releases
&lt;/h2&gt;

&lt;p&gt;fanotify isn't a static API; several recent additions touch this article's topics directly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FAN_MARK_EVICTABLE&lt;/code&gt; (5.19) belongs to the scale side: it doesn't pin marked inodes in memory, lowering the memory cost on wide trees. &lt;code&gt;FAN_MARK_IGNORE&lt;/code&gt; (6.0) is the modern replacement for the old ignore mask and the right way to narrow an event flood. &lt;code&gt;FAN_FS_ERROR&lt;/code&gt; (5.16) surfaces filesystem errors and &lt;code&gt;FAN_RENAME&lt;/code&gt; (5.17) renames as their own events.&lt;/p&gt;

&lt;p&gt;6.14 advanced on two fronts: mount namespace watching (&lt;code&gt;FAN_MARK_MNTNS&lt;/code&gt; plus mount attach/detach events) and genuine pre-content events (&lt;code&gt;FAN_PRE_ACCESS&lt;/code&gt;) — meaning the hierarchical storage manager scenario is no longer just a theoretical example in the man page.&lt;/p&gt;

&lt;h2&gt;
  
  
  When fanotify, and when not?
&lt;/h2&gt;

&lt;p&gt;The question for your own setup isn't "which API is more powerful" but "what am I going to do".&lt;/p&gt;

&lt;p&gt;If you're watching a handful of configuration files to reload a service, inotify is enough; there's no need to pay fanotify's &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt; price. If you're on a file server working out access patterns, answering "which share is actually used", or producing an audit trail, fanotify's notification class is the right tool: one mark covers an entire mount point.&lt;/p&gt;

&lt;p&gt;If you want to block access — malware scanning, a data classification gate, "this file must not be opened before it's restored from the archive" — the permission class is the only way. But then your listener is an infrastructure component: restarts, timeouts, crash scenarios and the question "what should happen when the listener isn't there" all become part of your design.&lt;/p&gt;

&lt;p&gt;If an audit trail is your actual goal, don't decide without comparing fanotify to auditd: auditd works at the system call level and gives richer user/process context, while fanotify works at the filesystem object level and comes with the ability to block. The rule structure in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/linux-auditd-ile-ayricalikli-komut-izleme-runbooku/" rel="noopener noreferrer"&gt;my runbook on monitoring privileged commands with Linux auditd&lt;/a&gt; answers "who ran what"; fanotify is strong on "which file was touched". Nor are they entirely separate worlds: open the fanotify group with &lt;code&gt;FAN_ENABLE_AUDIT&lt;/code&gt; and set the &lt;code&gt;FAN_AUDIT&lt;/code&gt; flag on responses, and your permission decisions land directly in the audit subsystem. You'll be shipping whatever you collect somewhere, which is a design job of its own.&lt;/p&gt;

&lt;p&gt;One more warning: file access records are inherently sensitive data. Which user opened which document is, in most organisations, more critical than the log itself. Decide on retention and access rights before you start collecting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Verify kernel support: &lt;code&gt;CONFIG_FANOTIFY&lt;/code&gt;, and &lt;code&gt;CONFIG_FANOTIFY_ACCESS_PERMISSIONS&lt;/code&gt; if you need permissions.&lt;/li&gt;
&lt;li&gt;Pick the scope: file/directory, &lt;code&gt;FAN_MARK_MOUNT&lt;/code&gt; or &lt;code&gt;FAN_MARK_FILESYSTEM&lt;/code&gt;. The wrong scope means either far too many events or missing coverage.&lt;/li&gt;
&lt;li&gt;Size &lt;code&gt;fs.fanotify.max_queued_events&lt;/code&gt; for your workload and always handle &lt;code&gt;FAN_Q_OVERFLOW&lt;/code&gt; — a listener that misses overflow has incomplete data.&lt;/li&gt;
&lt;li&gt;If you use descriptors, put the close on every branch of the code; if you don't, move to the &lt;code&gt;FAN_REPORT_FID&lt;/code&gt; family.&lt;/li&gt;
&lt;li&gt;In a permission class, test timeouts and the "listener died" scenario; run your first production attempt on a narrow mount point, not an entire filesystem.&lt;/li&gt;
&lt;li&gt;Document the blind spots — mmap, network filesystems, directory child events — so nobody has to ask later why something wasn't seen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The difference between watching and deciding
&lt;/h2&gt;

&lt;p&gt;What strikes me most about fanotify is that both capabilities live in the same API. Watching is passive: get it wrong and you collect incomplete data. Deciding is active: get it wrong and you stop the system.&lt;/p&gt;

&lt;p&gt;Because they sit behind the same interface, the two get conflated easily — "we're watching anyway, let's block too" sounds so innocent. Yet the moment you start blocking, your listener becomes a component as critical as your database.&lt;/p&gt;

&lt;p&gt;So the question for your own setup: do you really want to decide, or only to see? If it's the latter, stay in the notification class.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/plain/man/man7/fanotify.7" rel="noopener noreferrer"&gt;fanotify(7) — Linux man-pages, the filesystem event monitoring API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/plain/man/man2/fanotify_init.2" rel="noopener noreferrer"&gt;fanotify_init(2) — notification classes and group flags&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/plain/man/man2/fanotify_mark.2" rel="noopener noreferrer"&gt;fanotify_mark(2) — mark scopes and event masks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/tree/master/fs/notify/fanotify" rel="noopener noreferrer"&gt;Kernel source: fs/notify/fanotify&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>kernel</category>
      <category>security</category>
      <category>observability</category>
    </item>
    <item>
      <title>Sharing IO: io.max, io.weight and io.latency</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 06 Sep 2026 09:18:47 +0000</pubDate>
      <link>https://dev.to/merbayerp/sharing-io-iomax-ioweight-and-iolatency-4o03</link>
      <guid>https://dev.to/merbayerp/sharing-io-iomax-ioweight-and-iolatency-4o03</guid>
      <description>&lt;p&gt;When one process is choking the disk on a machine, the first reflex is always the same: "let's set a limit". Then someone writes an &lt;code&gt;io.max&lt;/code&gt; into a cgroup directory, the backup job is capped at 50 MB/s, and everyone relaxes. A few weeks later the backup window starts overrunning — while the disk sits idle most of the time.&lt;/p&gt;

&lt;p&gt;The reason for that story is that cgroup v2 doesn't have a single way to share IO. The kernel documentation lists four: absolute limits (blk-throttle), weight-based proportional distribution (iocost, or BFQ's own cgroup support when the BFQ scheduler is in use), latency-based protection, and IO priority assignment. This article covers the first three, because that trio drives the decision; the fourth (&lt;code&gt;io.prio.class&lt;/code&gt;) is worth keeping in your pocket for cheap moves like putting background jobs into the &lt;code&gt;idle&lt;/code&gt; class.&lt;/p&gt;

&lt;p&gt;Three mechanisms, three questions: &lt;code&gt;io.max&lt;/code&gt; says "this group must never exceed this rate", &lt;code&gt;io.weight&lt;/code&gt; says "when the disk is contended, split the share in this proportion", and &lt;code&gt;io.latency&lt;/code&gt; says "keep this group's latency under this target, slowing others down if necessary".&lt;/p&gt;

&lt;p&gt;My argument: most teams set up the first one when what they need is one of the other two. A hard cap doesn't protect the system — it only punishes a group and throws idle capacity away.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, see what you actually have
&lt;/h2&gt;

&lt;p&gt;Let's start with my own server: Ubuntu 24.04, a single SSD root disk (&lt;code&gt;sda&lt;/code&gt;, major:minor &lt;code&gt;8:0&lt;/code&gt;) and cgroup v2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/cgroup.subtree_control
&lt;span class="go"&gt;cpuset cpu io memory hugetlb pids rdma misc

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/io.weight
&lt;span class="go"&gt;default 100

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/io.stat
&lt;span class="go"&gt;7:5 rbytes=20272128 wbytes=0 rios=758 wios=0 dbytes=0 dios=0
7:4 rbytes=54236160 wbytes=0 rios=1842 wios=0 dbytes=0 dios=0
7:3 rbytes=755712 wbytes=0 rios=107 wios=0 dbytes=0 dios=0
7:2 rbytes=20448256 wbytes=0 rios=693 wios=0 dbytes=0 dios=0
7:1 rbytes=1116160 wbytes=0 rios=59 wios=0 dbytes=0 dios=0
7:0 rbytes=8969216 wbytes=0 rios=896 wios=0 dbytes=0 dios=0
8:0 rbytes=835511135232 wbytes=1563887470592 rios=43331841 wios=133872437 dbytes=699905486848 dios=352412

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/system.slice/io.pressure
&lt;span class="go"&gt;some avg10=0.08 avg60=0.05 avg300=0.10 total=7877797953
full avg10=0.00 avg60=0.00 avg300=0.04 total=6108319475
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first thing to learn from that output isn't policy, it's reading discipline: the first six lines are &lt;code&gt;7:x&lt;/code&gt; — &lt;strong&gt;loop devices&lt;/strong&gt;, the mount points of snap packages. The real disk is at the bottom, &lt;code&gt;8:0&lt;/code&gt;. Taking a device number from the first line of &lt;code&gt;io.stat&lt;/code&gt; is the easiest way to write a rule against the wrong device. systemd's documentation says the same thing separately: these settings should refer to the originating physical device, not to partitions or loopback devices.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;io.cost.qos&lt;/code&gt; and &lt;code&gt;io.cost.model&lt;/code&gt; files come back empty — I can't show their content in this article because there is nothing to show.&lt;/p&gt;

&lt;p&gt;What the picture says: the IO controller is enabled, accounting works, pressure is measured — but &lt;strong&gt;there is no policy at all&lt;/strong&gt;. &lt;code&gt;io.weight&lt;/code&gt; sits at the default 100.&lt;/p&gt;

&lt;p&gt;The gap has more than one cause. Per the kernel documentation, &lt;code&gt;io.cost.qos&lt;/code&gt; and &lt;code&gt;io.cost.model&lt;/code&gt; exist only on the root cgroup, and the line for a device is populated on the first write to one of those files. But that write isn't always yours: since systemd 254, udev's &lt;code&gt;iocost&lt;/code&gt; tool fills in &lt;code&gt;io.cost.*&lt;/code&gt; at boot on block devices for which it finds a matching solution in hwdb. My virtual disk has no match — &lt;code&gt;udevadm info /dev/sda | grep -i IOCOST&lt;/code&gt; comes back empty — which is why the files are empty. Don't be surprised if yours are populated; it may have configured itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three mechanisms, three questions
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtZb3UgaGF2ZSBkaXNrIGNvbnRlbnRpb25dIC0tPiBCe1doYXQncyB5b3VyIGFjdHVhbCBwcm9ibGVtP30KICBCIC0tPnxBIGdyb3VwIG11c3QgbmV2ZXI8YnIvPmV4Y2VlZCB0aGlzIHJhdGV8IENbaW8ubWF4PGJyLz5yYnBzL3dicHMvcmlvcHMvd2lvcHNdCiAgQiAtLT58U2hhcmUgZmFpcmx5PGJyLz53aGVuIGNvbnRlbmRlZHwgRFtpby53ZWlnaHQ8YnIvPm5lZWRzIHRoZSBpb2Nvc3QgbW9kZWxdCiAgQiAtLT58UHJvdGVjdCB0aGlzIGdyb3VwJ3M8YnIvPmxhdGVuY3l8IEVbaW8ubGF0ZW5jeTxici8-dGFyZ2V0ICsgcGVlciB0aHJvdHRsaW5nXQogIEMgLS0-IEZbTk9UIHdvcmsgY29uc2VydmluZzxici8-bGltaXQgYXBwbGllcyBldmVuIG9uIGFuIGlkbGUgZGlza10KICBEIC0tPiBHW1dvcmsgY29uc2VydmluZzxici8-aWRsZSBjYXBhY2l0eSBnZXRzIHVzZWRdCiAgRSAtLT4gSFtXb3JrIGNvbnNlcnZpbmc8YnIvPmEgZ3JvdXAgbWlzc2luZyBpdHMgdGFyZ2V0IHRocm90dGxlcyBwZWVyc10%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtZb3UgaGF2ZSBkaXNrIGNvbnRlbnRpb25dIC0tPiBCe1doYXQncyB5b3VyIGFjdHVhbCBwcm9ibGVtP30KICBCIC0tPnxBIGdyb3VwIG11c3QgbmV2ZXI8YnIvPmV4Y2VlZCB0aGlzIHJhdGV8IENbaW8ubWF4PGJyLz5yYnBzL3dicHMvcmlvcHMvd2lvcHNdCiAgQiAtLT58U2hhcmUgZmFpcmx5PGJyLz53aGVuIGNvbnRlbmRlZHwgRFtpby53ZWlnaHQ8YnIvPm5lZWRzIHRoZSBpb2Nvc3QgbW9kZWxdCiAgQiAtLT58UHJvdGVjdCB0aGlzIGdyb3VwJ3M8YnIvPmxhdGVuY3l8IEVbaW8ubGF0ZW5jeTxici8-dGFyZ2V0ICsgcGVlciB0aHJvdHRsaW5nXQogIEMgLS0-IEZbTk9UIHdvcmsgY29uc2VydmluZzxici8-bGltaXQgYXBwbGllcyBldmVuIG9uIGFuIGlkbGUgZGlza10KICBEIC0tPiBHW1dvcmsgY29uc2VydmluZzxici8-aWRsZSBjYXBhY2l0eSBnZXRzIHVzZWRdCiAgRSAtLT4gSFtXb3JrIGNvbnNlcnZpbmc8YnIvPmEgZ3JvdXAgbWlzc2luZyBpdHMgdGFyZ2V0IHRocm90dGxlcyBwZWVyc10%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="858" height="726"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  io.max: the hard ceiling
&lt;/h3&gt;

&lt;p&gt;(The &lt;code&gt;io.low&lt;/code&gt; you'll see in older guides is gone; that blk-throttle experiment was removed from the kernel.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;io.max&lt;/code&gt; is a nested file keyed by device number, taking &lt;code&gt;rbps&lt;/code&gt;, &lt;code&gt;wbps&lt;/code&gt;, &lt;code&gt;riops&lt;/code&gt; and &lt;code&gt;wiops&lt;/code&gt;. IOs are delayed once the limit is reached, and temporary bursts are allowed. For example, capping device &lt;code&gt;8:0&lt;/code&gt; at 2 MB/s reads and 120 write IOPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"8:0 rbps=2097152 wiops=120"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/fs/cgroup/backup.slice/io.max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical property is stated plainly in systemd's own documentation: this limit is &lt;strong&gt;not work-conserving&lt;/strong&gt;, and the processes are not allowed to use more even if the device has idle capacity. So at 3 a.m., with the disk completely idle, your backup still crawls at 50 MB/s.&lt;/p&gt;

&lt;p&gt;There's also persistence: a value you write into a cgroup file by hand can be overwritten by systemd on the next &lt;code&gt;daemon-reload&lt;/code&gt; or when the unit restarts. The durable route is systemd's own directives — &lt;code&gt;IOReadBandwidthMax=&lt;/code&gt;, &lt;code&gt;IOWriteBandwidthMax=&lt;/code&gt;, &lt;code&gt;IOReadIOPSMax=&lt;/code&gt;, &lt;code&gt;IOWriteIOPSMax=&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl set-property backup.slice &lt;span class="nv"&gt;IOWriteBandwidthMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/dev/sda 50M"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That makes &lt;code&gt;io.max&lt;/code&gt; a safety valve rather than a performance tool: it's right where predictability matters more than speed, when you want to say "this workload must never exceed this, whatever happens". Per-tenant ceilings in a rented environment, for instance.&lt;/p&gt;

&lt;h3&gt;
  
  
  io.weight: fairness, but only with a cost model
&lt;/h3&gt;

&lt;p&gt;What's usually wanted is this: as long as the disk isn't contended everyone runs free, and when it is, the share is split by weights. That's &lt;code&gt;io.weight&lt;/code&gt;, and the engine behind it is iocost.&lt;/p&gt;

&lt;p&gt;There's a setup step here that gets missed: for weights to mean anything you need one of two things. Either the device's scheduler is BFQ — in which case BFQ's own cgroup support handles the distribution — or iocost is enabled. On my server the scheduler is &lt;code&gt;none&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/block/sda/queue/scheduler
&lt;span class="go"&gt;[none] mq-deadline
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So there's no BFQ here; if I want weights, I have to enable iocost. The controller is disabled by default and enabled by setting &lt;code&gt;enable&lt;/code&gt; to 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# turn on weight-based control for device 8:0&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"8:0 enable=1 ctrl=auto"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/fs/cgroup/io.cost.qos
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ctrl=auto&lt;/code&gt; means the parameters are managed by the kernel. And when &lt;code&gt;rpct&lt;/code&gt; and &lt;code&gt;wpct&lt;/code&gt; are left at zero, the controller uses the device's internal saturation state to adjust the overall IO rate between the &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; percentages. If you need device-specific coefficients, &lt;code&gt;tools/cgroup/iocost_coef_gen.py&lt;/code&gt; in the kernel tree exists for that. When you need better control quality you can specify latency QoS parameters by hand — the documentation's example reads &lt;code&gt;enable=1 ctrl=auto rpct=95.00 rlat=75000 wpct=95.00 wlat=150000 min=50.00 max=...&lt;/code&gt;, i.e. scaling against a target like "the 95th percentile of reads shouldn't exceed 75 ms".&lt;/p&gt;

&lt;p&gt;The systemd equivalent is &lt;code&gt;IOWeight=&lt;/code&gt;: it takes a value between 1 and 10000 and controls the &lt;code&gt;io.weight&lt;/code&gt; attribute directly (which defaults to 100). The share is proportional: a unit's share is its weight divided by the sum of weights within the same slice. So &lt;code&gt;IOWeight=500&lt;/code&gt; means five times only if every neighbour sits at the default 100; raise the neighbours to 500 too and nothing changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  io.latency: protection, not punishment
&lt;/h3&gt;

&lt;p&gt;The third one is the least known: the most direct tool when you want to protect a single service. &lt;code&gt;io.latency&lt;/code&gt; defines a target latency and is work conserving — as long as everybody meets their target the controller does nothing. The syntax resembles the others:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"8:0 target=25000"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/fs/cgroup/database.slice/io.latency   &lt;span class="c"&gt;# 25 ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once a group starts missing its target, its peers get throttled. Two details here are the key to setting it up correctly.&lt;/p&gt;

&lt;p&gt;First: throttling is applied &lt;strong&gt;only at the peer level&lt;/strong&gt;. In the documentation's example, A, B and C under the root influence each other; D and F under A influence each other; groups in different branches never touch. Translated to systemd: two services under &lt;code&gt;system.slice&lt;/code&gt; are peers, &lt;code&gt;system.slice&lt;/code&gt; and &lt;code&gt;user.slice&lt;/code&gt; are peers at the root, and two services in different slices don't affect each other at all. Put the target at the wrong level and nothing happens — and nothing errors either.&lt;/p&gt;

&lt;p&gt;Second, and less known: the throttled peers don't need targets of their own. The comment in the kernel source is explicit — groups throttled as collateral have &lt;code&gt;min_lat_nsec == 0&lt;/code&gt;, i.e. groups where io.latency was never set. In practice: you protect one service, and its target-less neighbours back off.&lt;/p&gt;

&lt;p&gt;The throttling takes two forms: queue depth throttling (the number of outstanding IOs a group may have, clamped down all the way to one IO at a time) and artificial delay induction. The second is interesting, because IO types that can't be throttled — swapping and metadata IO — are allowed to proceed normally but are "charged" to the originating group.&lt;/p&gt;

&lt;p&gt;For picking a target the guidance is clear: don't set a value lower than the latency your device supports. On rotational devices use the &lt;code&gt;avg_lat&lt;/code&gt; value in &lt;code&gt;io.stat&lt;/code&gt; as a basis and set it 10-15% higher; on non-rotational devices no average latency is reported, so set the target from your device and verify with the &lt;code&gt;missed&lt;/code&gt;/&lt;code&gt;total&lt;/code&gt; fields. But run this line first, or you'll never see those fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;1 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/module/blk_cgroup/parameters/blkcg_debug_stats
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those debug stats are disabled by default; when latency fields are missing from &lt;code&gt;io.stat&lt;/code&gt;, the usual reason isn't a broken setting but a disabled measurement.&lt;/p&gt;

&lt;p&gt;The systemd equivalent is &lt;code&gt;IODeviceLatencyTargetSec=&lt;/code&gt;, which takes a device and a timespan, e.g. &lt;code&gt;/dev/sda 25ms&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A footnote: the introductory sentence in the kernel documentation says it throttles peers with a &lt;em&gt;lower&lt;/em&gt; target, while its own "How IO Latency Throttling Works" section says &lt;em&gt;higher&lt;/em&gt;. The source code supports the second; if you trip over that contradiction while reading, that's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layer that's easy to skip: writeback
&lt;/h2&gt;

&lt;p&gt;Everything above assumes IO can actually be charged to the right cgroup. With buffered writes that assumption collapses easily.&lt;/p&gt;

&lt;p&gt;Page cache is dirtied through buffered writes and shared mmaps, then written asynchronously by the writeback mechanism. The documentation treats this layer separately: the io controller, together with the memory controller, implements control of page cache writeback IOs — the memory controller defines the memory domain for which the dirty ratio is maintained, and the io controller defines the IO domain that writes those pages out. Both system-wide and per-cgroup dirty memory states are examined and &lt;strong&gt;the more restrictive of the two is enforced&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And the critical sentence: cgroup writeback requires explicit support from the underlying filesystem. The documentation lists the supported ones — ext2, ext4, btrfs, f2fs and xfs.&lt;/p&gt;

&lt;p&gt;In practice that means: if your filesystem isn't on that list, or the memory controller isn't enabled on the relevant hierarchy, buffered writes reach the disk with unclear ownership and your IO policy is largely ineffective on the write side. A good share of "I set a limit and the disk still fills up" complaints come from exactly here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one, when?
&lt;/h2&gt;

&lt;p&gt;Three questions, in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this workload's ceiling defined by a contract?&lt;/strong&gt; A per-tenant quota, IOPS sold to a customer, a "backups must never exceed this" policy — if yes, &lt;code&gt;io.max&lt;/code&gt;. Set it knowing it can't use idle capacity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is your problem the split under contention?&lt;/strong&gt; Several services sharing a disk and the sentence "the database matters more than the log shipper" — if yes, &lt;code&gt;io.weight&lt;/code&gt; (&lt;code&gt;IOWeight=&lt;/code&gt; on the systemd side). But turn iocost on first, or the weights are decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a specific latency you want to protect?&lt;/strong&gt; A user-facing service, a p99 target, "this service stays under 25 ms whatever the background jobs do" — if yes, &lt;code&gt;io.latency&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Let's be precise about combinations: &lt;code&gt;io.weight&lt;/code&gt; and &lt;code&gt;io.latency&lt;/code&gt; are two answers to the same question, and stacking them is pointless. Combining &lt;code&gt;io.max&lt;/code&gt; with either of them, on the other hand, is perfectly sensible — a contractual ceiling plus behaviour under contention. Still: measure first, then start with one mechanism.&lt;/p&gt;

&lt;p&gt;On container hosts, think about this trio alongside CPU and memory limits; the diagnostic flow in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/vpste-kaynak-canavar-container-tespit-edip-limitleme/" rel="noopener noreferrer"&gt;my article about spotting and limiting a resource-hog container on a VPS&lt;/a&gt; is the starting point. If storage latency under the virtualisation layer is in play, the questions in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/eski-nesil-sanallatrma-altyapsnda-storage-io-latency-savalar/" rel="noopener noreferrer"&gt;my piece on storage IO latency battles in legacy infrastructure&lt;/a&gt; apply too: the problem may not be cgroup policy but a shared queue underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measurement: where to look
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;io.stat&lt;/code&gt; → bytes and operations read/written per group; this is where you see which device is really in use.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;io.pressure&lt;/code&gt; → pressure, but read it correctly. The averages (&lt;code&gt;avg10/60/300&lt;/code&gt;) decay; an &lt;code&gt;avg300&lt;/code&gt; read at noon says nothing about the backup window at midnight. The right method is to take the delta of &lt;code&gt;total&lt;/code&gt; across the window you care about. And for IO the &lt;code&gt;full&lt;/code&gt; line is a sharper signal than &lt;code&gt;some&lt;/code&gt;: &lt;code&gt;full&lt;/code&gt; counts the time when every task in the system was waiting.&lt;/li&gt;
&lt;li&gt;If you set &lt;code&gt;io.latency&lt;/code&gt;, the &lt;code&gt;missed&lt;/code&gt;/&lt;code&gt;total&lt;/code&gt; fields in &lt;code&gt;io.stat&lt;/code&gt; → is the target actually being met?&lt;/li&gt;
&lt;li&gt;After the change, the workload's own metric → did the restriction protect what you wanted, or just move the problem?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As a checklist: get the device numbers (&lt;code&gt;$MAJ:$MIN&lt;/code&gt;) right and confirm with &lt;code&gt;lsblk&lt;/code&gt; that the device is the disk you meant; enable iocost if you're going to use weights; check the filesystem's cgroup writeback support for write-heavy workloads; and justify every policy with a pressure measurement first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting a limit isn't solving the problem
&lt;/h2&gt;

&lt;p&gt;Putting these three files side by side, what I see is the general state of resource management: the hard limit is the easiest tool to understand and the least useful. It's understandable because you give a number and the system obeys. It's not useful because the system's real problem isn't average throughput, it's behaviour under contention.&lt;/p&gt;

&lt;p&gt;If there's no contention you need no policy at all — as on my server. If there is, the question isn't "how many MB/s" but "who comes before whom, and what am I protecting?" The moment you know that answer, which file to write into is already decided.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/admin-guide/cgroup-v2.html" rel="noopener noreferrer"&gt;Linux kernel: cgroup v2 — the IO controller, writeback and io.latency&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/master/Documentation/admin-guide/cgroup-v2.rst" rel="noopener noreferrer"&gt;The source document in the kernel tree (cgroup-v2.rst)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/systemd/blob/main/man/systemd.resource-control.xml" rel="noopener noreferrer"&gt;systemd: resource control settings (IOWeight, IODeviceLatencyTargetSec)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>cgroupv2</category>
      <category>storage</category>
      <category>performance</category>
    </item>
    <item>
      <title>nftables flowtable: Speed Paid for in Visibility</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:56:44 +0000</pubDate>
      <link>https://dev.to/merbayerp/nftables-flowtable-speed-paid-for-in-visibility-3hn4</link>
      <guid>https://dev.to/merbayerp/nftables-flowtable-speed-paid-for-in-visibility-3hn4</guid>
      <description>&lt;p&gt;When you use a Linux box as a router, every packet takes the same journey: connection tracking, rule chain, NAT, rule chain again, egress. For a single connection that journey is negligible. On a box forwarding hundreds of thousands of packets per second, most of the CPU bill accumulates exactly there — and the interesting part is that the decision about the vast majority of those packets has already been made.&lt;/p&gt;

&lt;p&gt;Netfilter's flowtable infrastructure is built on that observation. The first packets of a flow go through the normal path and get the decision; after that the flow is written into a table, and packets matching it reach the egress without ever seeing the classic path.&lt;/p&gt;

&lt;p&gt;The real price of flowtable, paid in exchange for the CPU it saves, is &lt;strong&gt;visibility&lt;/strong&gt;. The speed you gain is paid for by losing the answer to "which rule saw this packet?" Set it up knowing that and it's an excellent tool; set it up without knowing and months later you'll be chasing a counter that doesn't move or a rule that never fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exactly the fast path skips
&lt;/h2&gt;

&lt;p&gt;The kernel documentation says it in one sentence: a packet that finds a matching entry in the flowtable is transmitted straight to the output netdevice, so packets bypass the classic IP forwarding path — and then the critical note in parentheses: &lt;strong&gt;the visible effect is that you do not see these packets from any of the Netfilter hooks coming after ingress.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If there's no match, the packet continues on the classic path. So flowtable isn't an all-or-nothing switch, it's a cache.&lt;/p&gt;

&lt;p&gt;An update is needed here, because this document hasn't been meaningfully revised since 2021. The text describes the transmit path via &lt;code&gt;neigh_xmit()&lt;/code&gt;; current kernels consolidated that path and call &lt;code&gt;dev_queue_xmit()&lt;/code&gt;. The difference isn't academic: on the &lt;code&gt;dev_queue_xmit()&lt;/code&gt; path, nftables' &lt;strong&gt;netdev egress&lt;/strong&gt; hook and the tc/qdisc layer are still in play. So what you lose on the software fast path isn't "all visibility" but &lt;strong&gt;the rules in the forward and postrouting hooks, and their counters&lt;/strong&gt;. Your traffic shaping (HTB, fq_codel, CAKE) keeps working; tc ingress and XDP run before the flowtable anyway, so they're unaffected too.&lt;/p&gt;

&lt;p&gt;With hardware offload it really is "you see nothing": the packet never reaches the CPU, so no rule, no qdisc and no egress hook is involved. The meaningful line is software versus hardware.&lt;/p&gt;

&lt;p&gt;The lookup uses this n-tuple: layer 2 encapsulation (VLAN and PPPoE), layer 3 source and destination, layer 4 source and destination ports, and the input interface. That last one matters for setups with several conntrack zones.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtQYWNrZXQgYXJyaXZlcyBhdCB0aGUgaW5ncmVzcyBob29rXSAtLT4gQntNYXRjaCBpbiB0aGUgZmxvd3RhYmxlP30KICBCIC0tPnxZZXN8IENbU3RyYWlnaHQgdG8gZWdyZXNzIHZpYSBuZWlnaF94bWl0PGJyLz5UVEwgZGVjcmVtZW50ZWQsIE5BVCBhcHBsaWVkXQogIEMgLS0-IERbTm8gbmV0ZmlsdGVyIGhvb2sgYWZ0ZXIgaW5ncmVzczxici8-c2VlcyB0aGUgcGFja2V0XQogIEIgLS0-fE5vfCBFW0NsYXNzaWMgSVAgZm9yd2FyZGluZyBwYXRoPGJyLz5jb25udHJhY2sgKyBydWxlIGNoYWluXQogIEUgLS0-IEZ7RG9lcyBhIHJ1bGUgc2F5ICdmbG93IGFkZCBAZic_fQogIEYgLS0-fFllc3wgR1tGbG93IGlzIGFkZGVkIHRvIHRoZSBmbG93dGFibGU8YnIvPmxhdGVyIHBhY2tldHMgdGFrZSB0aGUgZmFzdCBwYXRoXQogIEYgLS0-fE5vfCBIW0V2ZXJ5IHBhY2tldCB0YWtlcyB0aGUgY2xhc3NpYyBwYXRoXQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtQYWNrZXQgYXJyaXZlcyBhdCB0aGUgaW5ncmVzcyBob29rXSAtLT4gQntNYXRjaCBpbiB0aGUgZmxvd3RhYmxlP30KICBCIC0tPnxZZXN8IENbU3RyYWlnaHQgdG8gZWdyZXNzIHZpYSBuZWlnaF94bWl0PGJyLz5UVEwgZGVjcmVtZW50ZWQsIE5BVCBhcHBsaWVkXQogIEMgLS0-IERbTm8gbmV0ZmlsdGVyIGhvb2sgYWZ0ZXIgaW5ncmVzczxici8-c2VlcyB0aGUgcGFja2V0XQogIEIgLS0-fE5vfCBFW0NsYXNzaWMgSVAgZm9yd2FyZGluZyBwYXRoPGJyLz5jb25udHJhY2sgKyBydWxlIGNoYWluXQogIEUgLS0-IEZ7RG9lcyBhIHJ1bGUgc2F5ICdmbG93IGFkZCBAZic_fQogIEYgLS0-fFllc3wgR1tGbG93IGlzIGFkZGVkIHRvIHRoZSBmbG93dGFibGU8YnIvPmxhdGVyIHBhY2tldHMgdGFrZSB0aGUgZmFzdCBwYXRoXQogIEYgLS0-fE5vfCBIW0V2ZXJ5IHBhY2tldCB0YWtlcyB0aGUgY2xhc3NpYyBwYXRoXQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="750" height="1090"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: one table, one rule
&lt;/h2&gt;

&lt;p&gt;The documentation's example is as plain as it gets — you define a flowtable and add a single rule to the forward chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table inet x {
    flowtable f {
        hook ingress priority 0; devices = { eth0, eth1 };
    }
    chain y {
        type filter hook forward priority 0; policy accept;
        ip protocol tcp flow add @f
        counter packets 0 bytes 0
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;flow add @f&lt;/code&gt; statement means &lt;em&gt;you&lt;/em&gt; choose which flows take the fast path. The documentation is explicit about this: packets follow the classic path unless the user explicitly instructs otherwise. Flowtable doesn't kick in by default; it is enabled by policy.&lt;/p&gt;

&lt;p&gt;If you need it, you can create several flowtables for resource partitioning. The flowtable priority defines the order in which hooks run in the pipeline. If you already have an nftables ingress chain, the documentation's rule is explicit: keep the flowtable priority &lt;strong&gt;smaller&lt;/strong&gt; than that chain's, so the flowtable runs first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What falls off the fast path
&lt;/h2&gt;

&lt;p&gt;Not every packet qualifies, and the rules make sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fragmented traffic&lt;/strong&gt; goes to the classic path, because with the transport header missing a flowtable lookup isn't possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP RST and FIN&lt;/strong&gt; packets are described by the documentation as passed up, but that has gone stale: since the change landed in 6.14 these packets mark the flow as &lt;code&gt;CLOSING&lt;/code&gt; and stay on the fast path; the flow is torn down only when a new SYN arrives on a closing connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packets exceeding the MTU&lt;/strong&gt; take the classic path too, so a packet-too-big ICMP error can be reported to the sender.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The list needs additions that aren't in the documentation but sit in the source, because "why aren't some of my flows accelerated" is usually answered here: flows with a conntrack helper attached — FTP, SIP, TFTP and friends — are never offloaded; packets carrying IPsec, IPv4 packets with options, and connections marked with sequence adjustment or NAT clash stay out too. On the TCP side the flow must be established. And there's a configuration constraint: &lt;code&gt;flow add&lt;/code&gt; is only valid in the forward chain.&lt;/p&gt;

&lt;p&gt;On the NAT side: the flowtable stores the NAT configuration, and packets are mangled according to the NAT policy specified from the classic path. The TTL is decremented before &lt;code&gt;neigh_xmit()&lt;/code&gt; is called. So the fast path doesn't skip the router's duties, only the decision chain.&lt;/p&gt;

&lt;p&gt;To see which flows took the fast path, look at the connection tracking table; offloaded flows carry the &lt;code&gt;[OFFLOAD]&lt;/code&gt; tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;conntrack &lt;span class="nt"&gt;-L&lt;/span&gt;
&lt;span class="go"&gt;tcp  6 src=10.141.10.2 dst=192.168.10.2 sport=52728 dport=5201 ... [OFFLOAD] mark=0 use=2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Visibility: what exactly do you lose?
&lt;/h2&gt;

&lt;p&gt;In the example above the rule ends with a &lt;code&gt;counter&lt;/code&gt;. The documentation states plainly that once the flow is offloaded, &lt;strong&gt;that counter no longer gets updated&lt;/strong&gt; for packets forwarded through the bypass.&lt;/p&gt;

&lt;p&gt;This holds for everything rule-based: counters, log statements, filter rules further down, marking. You wrote the rule, but the packet never visits it.&lt;/p&gt;

&lt;p&gt;Don't assume you lose all accounting, though. You can add a &lt;code&gt;counter&lt;/code&gt; statement to the flowtable itself, in which case it synchronises packet and byte counters with the existing connection tracking entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table inet x {
    flowtable f {
        hook ingress priority 0; devices = { eth0, eth1 }; counter
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That support has been available since kernel 5.7. On its own, though, it isn't enough: the counters are written into the conntrack entry, so conntrack accounting has to be on too — and it's off by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.netfilter.nf_conntrack_acct&lt;span class="o"&gt;=&lt;/span&gt;1   &lt;span class="c"&gt;# make it permanent in /etc/sysctl.d/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: the accounting extension is attached when the conntrack entry is created, so enabling this later affects new connections only. Do both and flow-level accounting stays with you; what you lose is rule-level detail. Clarify that distinction before deployment: if your monitoring reports "how many packets did each rule see", that report starts lying quietly the moment flowtable comes online.&lt;/p&gt;

&lt;p&gt;There's a second cost next to visibility, and it gets even less attention: when a flow is offloaded, the kernel disables TCP window validation for that conntrack entry. The comment in the source states the reason plainly — conntrack will no longer see all the packets, so sequence and window checking becomes meaningless. In other words, state validation is relaxed for the flows you put on the fast path; whether that's acceptable depends on how trusted the flow is.&lt;/p&gt;

&lt;p&gt;The same distinction applies to security. If you built detailed egress control like the one in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/nftables-ile-cikis-trafigi-politika-katmani/" rel="noopener noreferrer"&gt;my article on an nftables egress policy layer&lt;/a&gt;, you have to narrow your &lt;code&gt;flow add&lt;/code&gt; condition knowing that those rules won't inspect the offloaded flows. Opening the fast path only to already-decided, trusted traffic is mandatory here.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long does a flow stay on the fast path?
&lt;/h2&gt;

&lt;p&gt;The behaviour that confuses people most after setup: a flow shows up on the fast path, disappears, then comes back. The reason is timeouts, and they have their own sysctls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sysctl net.netfilter.nf_flowtable_tcp_timeout net.netfilter.nf_flowtable_udp_timeout
&lt;span class="go"&gt;net.netfilter.nf_flowtable_tcp_timeout = 30
net.netfilter.nf_flowtable_udp_timeout = 30
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the kernel documentation's words these control the offload timeout: TCP and UDP connections may be offloaded from conntrack to the flow table, and &lt;strong&gt;once aged, the connection is returned to conntrack&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The important detail: this is an idle timer. Every packet taking the fast path refreshes the timeout, so a flow with traffic doesn't drop off every 30 seconds. What drops off are flows that genuinely carry no packet for 30 seconds — sparse heartbeat channels, idle sessions. When those come back, your rule counters tick by a packet or two per flow; that's why you see small increments where you expected "the counter never moves".&lt;/p&gt;

&lt;p&gt;That's why you shouldn't judge the fast path from a single snapshot; watch the number of &lt;code&gt;[OFFLOAD]&lt;/code&gt;-tagged flows in &lt;code&gt;conntrack -L&lt;/code&gt; over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware offload and bridges
&lt;/h2&gt;

&lt;p&gt;If your network device supports hardware offload, adding &lt;code&gt;flags offload;&lt;/code&gt; to the flowtable definition is enough. A workqueue adds flows to the hardware; the documentation warns that a few packets may still run over the software fast path until the workqueue gets a chance. Flows that made it to hardware appear with the &lt;code&gt;[HW_OFFLOAD]&lt;/code&gt; tag in conntrack. The infrastructure also supports DSA (Distributed Switch Architecture).&lt;/p&gt;

&lt;p&gt;At layer 2 things got easier in 5.13: the flowtable discovers the real netdevice behind VLAN and PPPoE devices, parses those headers, and uses the ethertype and VLAN/PPPoE session id for lookups. So you don't need to add the VLAN and PPPoE devices to your flowtable definition; the real device is sufficient.&lt;/p&gt;

&lt;p&gt;For bridges the rule is the opposite, and the difference is easy to miss: if you want a fast path between your bridge ports and the IP forwarding path, &lt;strong&gt;you have to add the bridge ports (as represented by the real netdevice) to your flowtable definition&lt;/strong&gt;. With VLAN/PPPoE you don't need to add them; with a bridge you must. The infrastructure then discovers the topology behind the bridge device and establishes a fast path between bridge ports and the gateway device; bridge VLAN filtering (PVID, untagged) is supported as well. For people building home or branch routers, that's usually where the real gain is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The price of being a cache: stale entries
&lt;/h2&gt;

&lt;p&gt;The documentation's "limitations" section is short but important: the flowtable behaves like a cache and entries might get stale if either the destination MAC address or the egress netdevice used for transmission changes. Two situations are called out: running the flowtable in software mode while combining bridge and IP forwarding, and having hardware offload enabled.&lt;/p&gt;

&lt;p&gt;In practice that means care in setups with moving topology: a router switching between redundant uplinks, virtual MACs carried by VRRP, bridge ports relearned after a cable change. In such an environment, test the failover scenario deliberately before enabling the fast path: the real question is what happens the moment the link changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need it on your box?
&lt;/h2&gt;

&lt;p&gt;Looking at my own VPS, the answer is clearly no:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nft &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="gp"&gt;nftables v1.0.9 (Old Doc Yak #&lt;/span&gt;3&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /lib/modules/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/kernel/net/netfilter/ | &lt;span class="nb"&gt;grep &lt;/span&gt;flow
&lt;span class="go"&gt;nf_flow_table.ko.zst
nf_flow_table_inet.ko.zst
nft_flow_offload.ko.zst

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;lsmod | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; nf_flow
&lt;span class="go"&gt;0

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/net/netfilter/nf_conntrack_count /proc/sys/net/netfilter/nf_conntrack_max
&lt;span class="go"&gt;745
262144
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The modules exist, they aren't loaded, and they don't need to be: this machine is an application server, forwarded traffic is negligible, and the conntrack table hovers around 745 entries out of a 262k capacity. The CPU flowtable would save here disappears into measurement noise.&lt;/p&gt;

&lt;p&gt;Where it does add value is clear: edge boxes forwarding at high packet rates, home and branch routers, VPN gateways, heavy east-west traffic on container hosts. What they share is millions of packets from the same flow passing through the same decision over and over.&lt;/p&gt;

&lt;p&gt;Where it doesn't is equally clear: traffic you want to inspect deeply, environments doing detailed rule-level accounting, and setups whose topology keeps changing. On the IDS/IPS side the concrete trap is NFQUEUE: if you run Suricata or Snort inline in the forward chain, offloaded packets never enter that queue — silently. Your inspection rule is still there, your engine is running, but part of the traffic no longer passes through it.&lt;/p&gt;

&lt;p&gt;Let's also correct a conntrack expectation: offloading doesn't reduce conntrack entries. The entry stays, it just gets the &lt;code&gt;[OFFLOAD]&lt;/code&gt; tag. Flowtable is not a cure for a filling conntrack table.&lt;/p&gt;

&lt;p&gt;If you want to measure the gain, &lt;code&gt;conntrack -L | grep -c OFFLOAD&lt;/code&gt; alone won't do; compare forwarded packet rate and softirq CPU (&lt;code&gt;mpstat -P ALL&lt;/code&gt;, &lt;code&gt;/proc/net/softnet_stat&lt;/code&gt;) before and after. I didn't run that measurement on my own box for this article — there's no load to measure; do it before you enable it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you enable it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep the &lt;code&gt;flow add&lt;/code&gt; condition narrow: only decided, trusted flows (specific interface pairs and protocols, say).&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;counter&lt;/code&gt; to the flowtable definition so you keep flow-level accounting.&lt;/li&gt;
&lt;li&gt;Review monitoring dashboards that rely on rule counters or logs; offloaded flows won't appear there.&lt;/li&gt;
&lt;li&gt;Check for the &lt;code&gt;[OFFLOAD]&lt;/code&gt; and &lt;code&gt;[HW_OFFLOAD]&lt;/code&gt; tags in &lt;code&gt;conntrack -L&lt;/code&gt;: are the flows you expected really on the fast path?&lt;/li&gt;
&lt;li&gt;Test failover, VLAN changes and bridge topology scenarios; the stale-entry risk lives there.&lt;/li&gt;
&lt;li&gt;With hardware offload, verify your NIC driver support and account for the first packets still taking the software path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/nftables-ile-modern-linux-firewall-iptablestan-gecis/" rel="noopener noreferrer"&gt;my article about moving from iptables to nftables&lt;/a&gt; we talked about the readability of a rule set; flowtable changes &lt;em&gt;how much of that rule set actually runs&lt;/em&gt;. The two belong in the same conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed always comes from the same place
&lt;/h2&gt;

&lt;p&gt;What flowtable does is computing's oldest trick: instead of making the same decision over and over, store the result. And like every stored decision, a gap can open between it and reality — an entry going stale when a MAC address changes, a counter that stops moving, a packet that never reaches the queue.&lt;/p&gt;

&lt;p&gt;So the question for your own setup: how many times is the same decision made on this machine? If the answer is millions, flowtable has a place; if it's a few thousand, what you get for the lost visibility is speed you won't be able to measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/networking/nf_flowtable.html" rel="noopener noreferrer"&gt;Linux kernel: Netfilter flowtable infrastructure&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/master/Documentation/networking/nf_flowtable.rst" rel="noopener noreferrer"&gt;The source document in the kernel tree (nf_flowtable.rst)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/networking/nf_conntrack-sysctl.html" rel="noopener noreferrer"&gt;Linux kernel: connection tracking sysctl reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>nftables</category>
      <category>kernel</category>
    </item>
    <item>
      <title>Compressed Swap: zram or zswap?</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 06 Sep 2026 08:48:59 +0000</pubDate>
      <link>https://dev.to/merbayerp/compressed-swap-zram-or-zswap-14cp</link>
      <guid>https://dev.to/merbayerp/compressed-swap-zram-or-zswap-14cp</guid>
      <description>&lt;p&gt;No corner of server discussions surrenders to slogans faster than swap. On one side, "turn swap off, RAM is cheap"; on the other, people blaming swap after every freeze. Both make the same mistake: they treat swap as a single thing.&lt;/p&gt;

&lt;p&gt;In a modern kernel, compressed swap exists as two separate mechanisms, and because their names look alike they get confused constantly. &lt;strong&gt;zram&lt;/strong&gt; is a compressed block device; you put swap on it and it never touches the disk. &lt;strong&gt;zswap&lt;/strong&gt; is a compressed cache in front of your existing swap area; it catches pages before they go to disk.&lt;/p&gt;

&lt;p&gt;The thesis of this article is simple: choosing between them isn't a matter of taste, it's an architectural decision determined by the machine's disk situation. And neither of them creates memory — they trade CPU for disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neither is present on my server, and the reason is interesting
&lt;/h2&gt;

&lt;p&gt;Let's start with my server running Ubuntu 24.04, because the picture is typical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;modinfo zram
&lt;span class="go"&gt;modinfo: ERROR: Module zram not found.

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;apt-cache policy linux-modules-extra-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;linux-modules-extra-6.8.0-138-generic:
  Installed: (none)
  Candidate: 6.8.0-138.138

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"CONFIG_ZSWAP|CONFIG_ZSWAP_DEFAULT_ON"&lt;/span&gt; /boot/config-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;CONFIG_ZSWAP=y
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;CONFIG_ZSWAP_DEFAULT_ON is not &lt;span class="nb"&gt;set&lt;/span&gt;
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/module/zswap/parameters/enabled
&lt;span class="go"&gt;N
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two different kinds of "absent" here. zram really is absent: on Ubuntu the module ships in a separate package (&lt;code&gt;linux-modules-extra&lt;/code&gt;) which isn't installed on this machine. zswap, on the other hand, is compiled into the kernel but off by default — one line turns it on.&lt;/p&gt;

&lt;p&gt;That distinction matters in practice. Someone saying "let me try zram" on this server starts with a package installation; someone saying "let me try zswap" starts with an &lt;code&gt;echo&lt;/code&gt;. That's two very different preparation costs on the same machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  They don't do the same job
&lt;/h2&gt;

&lt;p&gt;Broadly speaking, both say "compress the swap page", but architecturally they sit in different places.&lt;/p&gt;

&lt;p&gt;zram, in the kernel documentation's words, creates compressed RAM-based block devices: pages written to these disks are compressed and stored in memory itself. So &lt;code&gt;/dev/zram0&lt;/code&gt; is an ordinary block device as far as you're concerned; you run &lt;code&gt;mkswap&lt;/code&gt; on it, say &lt;code&gt;swapon&lt;/code&gt;, and your system's swap area now lives inside RAM in compressed form. It has no relationship with the disk — unless you want one.&lt;/p&gt;

&lt;p&gt;zswap isn't a device but a cache. As the documentation puts it, it's a lightweight compressed cache that takes pages in the process of being swapped out and attempts to compress them into a dynamically allocated RAM-based pool. When the pool reaches its size limit, it evicts pages on an LRU basis to the backing swap device. So zswap needs a swap area on disk to work; it's a buffer in front of that area.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtLZXJuZWwgZGVjaWRlcyB0byBzd2FwPGJyLz5hIHBhZ2Ugb3V0XSAtLT4gQntXaGljaCBzZXR1cD99CiAgQiAtLT58enJhbXwgQ1tQYWdlIGlzIHdyaXR0ZW4gdG8gL2Rldi96cmFtMDxici8-Y29tcHJlc3NlZCBpbnNpZGUgUkFNXQogIEMgLS0-IERbTm8gZGlzayB0cmFmZmljPGJyLz5jb3N0OiBDUFUgKyBSQU0gZm9yIGNvbXByZXNzZWQgZGF0YV0KICBCIC0tPnx6c3dhcHwgRVtQYWdlIGlzIGNvbXByZXNzZWQgaW50bzxici8-dGhlIFJBTSBwb29sIGZpcnN0XQogIEUgLS0-IEZ7SXMgdGhlIHBvb2wgZnVsbD99CiAgRiAtLT58Tm98IEdbRGlzayB0cmFmZmljIGRlZmVycmVkXQogIEYgLS0-fFllc3wgSFtMUlUgZXZpY3Rpb24gdG8gdGhlPGJyLz5yZWFsIHN3YXAgYXJlYSBiZWhpbmQgaXRdCiAgQiAtLT58bmVpdGhlcnwgSVtQYWdlIGdvZXMgc3RyYWlnaHQgdG8gZGlza10%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtLZXJuZWwgZGVjaWRlcyB0byBzd2FwPGJyLz5hIHBhZ2Ugb3V0XSAtLT4gQntXaGljaCBzZXR1cD99CiAgQiAtLT58enJhbXwgQ1tQYWdlIGlzIHdyaXR0ZW4gdG8gL2Rldi96cmFtMDxici8-Y29tcHJlc3NlZCBpbnNpZGUgUkFNXQogIEMgLS0-IERbTm8gZGlzayB0cmFmZmljPGJyLz5jb3N0OiBDUFUgKyBSQU0gZm9yIGNvbXByZXNzZWQgZGF0YV0KICBCIC0tPnx6c3dhcHwgRVtQYWdlIGlzIGNvbXByZXNzZWQgaW50bzxici8-dGhlIFJBTSBwb29sIGZpcnN0XQogIEUgLS0-IEZ7SXMgdGhlIHBvb2wgZnVsbD99CiAgRiAtLT58Tm98IEdbRGlzayB0cmFmZmljIGRlZmVycmVkXQogIEYgLS0-fFllc3wgSFtMUlUgZXZpY3Rpb24gdG8gdGhlPGJyLz5yZWFsIHN3YXAgYXJlYSBiZWhpbmQgaXRdCiAgQiAtLT58bmVpdGhlcnwgSVtQYWdlIGdvZXMgc3RyYWlnaHQgdG8gZGlza10%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="855" height="835"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The operational consequence: zram makes swap possible on machines with no disk, or whose disk you'd rather not wear out. zswap makes the swap you already have less painful.&lt;/p&gt;

&lt;h2&gt;
  
  
  zram: setting up the device and measuring it
&lt;/h2&gt;

&lt;p&gt;The zram flow is short. Load the module, size the device, pick an algorithm, put swap on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;modprobe zram &lt;span class="nv"&gt;num_devices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;echo &lt;/span&gt;zstd &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/block/zram0/comp_algorithm
&lt;span class="nb"&gt;echo &lt;/span&gt;4G   &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/block/zram0/disksize
mkswap /dev/zram0 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; swapon &lt;span class="nt"&gt;--priority&lt;/span&gt; 100 /dev/zram0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To add a device at runtime, reading &lt;code&gt;/sys/class/zram-control/hot_add&lt;/code&gt; returns a new device id, and &lt;code&gt;hot_remove&lt;/code&gt; takes one away. You see the available algorithms by reading &lt;code&gt;comp_algorithm&lt;/code&gt; — the selected one appears in brackets.&lt;/p&gt;

&lt;p&gt;For sizing, the documentation has its own rule and it's eminently sensible: since a 2:1 compression ratio is expected, there's little point creating a zram larger than twice the size of memory. If you want a hard guarantee anyway, &lt;code&gt;mem_limit&lt;/code&gt; caps how much memory the device may use.&lt;/p&gt;

&lt;p&gt;The real work starts after setup: measuring. The &lt;code&gt;mm_stat&lt;/code&gt; file gives you the truth in a single line: &lt;code&gt;orig_data_size&lt;/code&gt; is the uncompressed data size, &lt;code&gt;compr_data_size&lt;/code&gt; its compressed form, &lt;code&gt;mem_used_total&lt;/code&gt; the memory actually allocated including fragmentation, &lt;code&gt;same_pages&lt;/code&gt; the identical pages that need no storage at all, and &lt;code&gt;huge_pages&lt;/code&gt; the incompressible ones.&lt;/p&gt;

&lt;p&gt;That last field is the number anyone running zram in production needs to watch. An incompressible page means zram is spending memory while giving you nothing. On a workload carrying already-compressed data — encrypted files, media, pre-compressed database pages — &lt;code&gt;huge_pages&lt;/code&gt; climbs fast and the "I'm saving memory" story quietly reverses. To see the real ratio, divide &lt;code&gt;orig_data_size&lt;/code&gt; by &lt;code&gt;mem_used_total&lt;/code&gt;: the memory you pay for is &lt;code&gt;mem_used_total&lt;/code&gt;. Comparing it against &lt;code&gt;compr_data_size&lt;/code&gt; measures something else — the allocator's space efficiency.&lt;/p&gt;

&lt;p&gt;Now zram's most critical and least discussed property: &lt;strong&gt;when it fills up, there is nowhere to fall back to.&lt;/strong&gt; A full zswap pool evicts pages to the real swap behind it; zram has no such tier — a full device is just a block device at capacity, and the system walks straight towards OOM.&lt;/p&gt;

&lt;p&gt;The antidote is &lt;code&gt;backing_dev&lt;/code&gt;. You define a backing device and use &lt;code&gt;writeback&lt;/code&gt; to push idle or incompressible pages to a real disk. Two setup constraints will make you fail silently: the backing device must be set &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;disksize&lt;/code&gt;, and only partitions are supported at the moment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; /dev/sda5 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/block/zram0/backing_dev   &lt;span class="c"&gt;# this first&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;4G        &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/block/zram0/disksize      &lt;span class="c"&gt;# then this&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;1         &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/block/zram0/writeback_limit_enable
&lt;span class="nb"&gt;echo &lt;/span&gt;4096      &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/block/zram0/writeback_limit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third line matters: &lt;code&gt;writeback_limit_enable&lt;/code&gt; defaults to 0, and while it is 0 the &lt;code&gt;writeback_limit&lt;/code&gt; value you wrote is meaningless. You'd think you set a budget when you didn't.&lt;/p&gt;

&lt;p&gt;There's one more capability: recompression. In a multi-compression configuration you can define one primary and up to three secondary algorithms, then use &lt;code&gt;recompress&lt;/code&gt; to re-compress specific page types with a stronger algorithm.&lt;/p&gt;

&lt;p&gt;Don't skip the second half of the sizing rule either: the documentation notes that an unused zram costs about 0.1% of the disk size, so a huge zram is wasteful.&lt;/p&gt;

&lt;p&gt;On the system side, rather than wiring this up by hand, systemd's &lt;code&gt;zram-generator&lt;/code&gt; is cleaner: it creates the device and sets up swap at boot, with the configuration living in a single file.&lt;/p&gt;

&lt;h2&gt;
  
  
  zswap: a buffer you enable with one echo
&lt;/h2&gt;

&lt;p&gt;The zswap side is less setup, more tuning. To turn it on at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;1 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/module/zswap/parameters/enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make it permanent you add &lt;code&gt;zswap.enabled=1&lt;/code&gt; to the kernel command line; if your distribution ships with &lt;code&gt;CONFIG_ZSWAP_DEFAULT_ON&lt;/code&gt; it's already active.&lt;/p&gt;

&lt;p&gt;All the knobs live under &lt;code&gt;/sys/module/zswap/parameters/&lt;/code&gt;, and here's how they look on my server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; /sys/module/zswap/parameters/ | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s|.*parameters/||'&lt;/span&gt;
&lt;span class="go"&gt;same_filled_pages_enabled:Y
enabled:N
shrinker_enabled:Y
max_pool_percent:20
compressor:lzo
non_same_filled_pages_enabled:Y
zpool:zbud
exclusive_loads:N
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That output is from Ubuntu 24.04 / kernel 6.8, and part of it is already history: upstream removed the zbud and z3fold allocators along with the &lt;code&gt;zpool&lt;/code&gt; parameter — the current zswap documentation says it in one line, zsmalloc manages the compressed pool. &lt;code&gt;exclusive_loads&lt;/code&gt; and &lt;code&gt;same_filled_pages_enabled&lt;/code&gt; are gone from today's kernel source too. So some of these knobs may simply not exist on your machine; instead of memorising the list, read &lt;code&gt;grep -r . /sys/module/zswap/parameters/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The three that persist are these. &lt;code&gt;max_pool_percent&lt;/code&gt; is the maximum percentage of memory the compressed pool may occupy — default 20, a fifth of your RAM. &lt;code&gt;compressor&lt;/code&gt; is the compression algorithm; this kernel's default is LZO. &lt;code&gt;accept_threshold_percent&lt;/code&gt; is the hysteresis threshold for when to start accepting pages again after the pool fills.&lt;/p&gt;

&lt;p&gt;There's also &lt;code&gt;shrinker_enabled&lt;/code&gt;, which turns on the shrinker that proactively writes cold pages out to swap. On my kernel it ships enabled — but since zswap itself is off, it has no effect.&lt;/p&gt;

&lt;p&gt;Know from the outset that turning it off isn't symmetric with turning it on: the documentation says that when zswap is disabled at runtime it stops storing pages, but does not immediately write out or fault back the pages already in the compressed pool. To actually drain the pool you have to run &lt;code&gt;swapoff&lt;/code&gt; on the swap device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rethinking swappiness
&lt;/h2&gt;

&lt;p&gt;Setting up compressed swap and leaving &lt;code&gt;vm.swappiness&lt;/code&gt; alone is a job half done. That knob answers the kernel's question of "how expensive is swap I/O relative to filesystem paging?", and the default of 60 is an estimate from the world of spinning disks.&lt;/p&gt;

&lt;p&gt;The kernel documentation is surprisingly explicit here: the value ranges from 0 to 200, at 100 the VM treats both costs as equal, and &lt;strong&gt;for in-memory swap like zram or zswap, values beyond 100 can be considered&lt;/strong&gt;. The docs even give the arithmetic: if random I/O against the swap device is on average 2x faster than filesystem I/O, swappiness should be 133 (x + 2x = 200).&lt;/p&gt;

&lt;p&gt;Here are the values on my own server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;free &lt;span class="nt"&gt;-m&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
&lt;span class="go"&gt;               total        used        free      shared  buff/cache   available
Mem:           96556       17525       69760        1595       11839       79031

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;swapon &lt;span class="nt"&gt;--show&lt;/span&gt;
&lt;span class="go"&gt;NAME      TYPE SIZE USED PRIO
/swapfile file   8G   0B   -2

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sysctl vm.swappiness vm.page-cluster
&lt;span class="go"&gt;vm.swappiness = 10
vm.page-cluster = 3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;swappiness is 10, meaning the system treats writing to swap as almost a last resort compared with dropping page cache. That's defensible for a swapfile on disk; but if that swap moved to zram, the same 10 would mean deliberately tying your own hands.&lt;/p&gt;

&lt;p&gt;The second line gets less attention and deserves as much: &lt;code&gt;vm.page-cluster&lt;/code&gt; is a logarithmic value controlling how many consecutive pages are read from swap in one attempt, and it defaults to 3 — eight pages. That behaviour makes sense for spinning disks, where fetching neighbouring pages costs little extra. In compressed swap each page is decompressed individually, and seven of those eight may have been decompressed for nothing. Setting it to zero disables swap readahead entirely, which is the common recommendation for zram setups.&lt;/p&gt;

&lt;p&gt;Changing the mechanism without changing the cost assumptions is the most common mistake in compressed swap setups — and that assumption isn't one knob, it's at least two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one for which machine?
&lt;/h2&gt;

&lt;p&gt;The first question in the decision tree: is there a swap area on this machine that you want to use?&lt;/p&gt;

&lt;p&gt;If there isn't and you don't want one — a laptop whose SSD lifetime you're protecting, a diskless edge device, a VM on a tight disk quota — the answer is zram. Swap lives entirely in RAM and the disk never enters the picture.&lt;/p&gt;

&lt;p&gt;If there is, and your actual complaint is the machine grinding to a halt whenever it swaps, the answer is zswap. Some pages stay in the RAM pool without ever reaching disk, disk traffic drops, and your existing swap area stands behind it as insurance.&lt;/p&gt;

&lt;p&gt;Using both at once is theoretically possible, but I wouldn't recommend it — and not only because you pay CPU twice. When pages evicted from the zswap pool land in zram, cold pages stay locked in fast RAM and the LRU logic inverts. You end up with a setup doing the opposite of what you wanted, which was keeping the active working set in RAM.&lt;/p&gt;

&lt;p&gt;So why is neither present on my 96 GB server? Because swap there is already just insurance; the machine sits at 17 GB in use and the 8 GB file swap in &lt;code&gt;swapon --show&lt;/code&gt; reports zero usage. Compressed swap helps systems that actually &lt;em&gt;fall into&lt;/em&gt; swap. On a system that never does, every mechanism you add raises more questions than it answers.&lt;/p&gt;

&lt;p&gt;And there's the obvious truth nobody says out loud: neither mechanism creates memory. Compressed pages still live in RAM, they just take up less room. Under genuine memory pressure zram buys you time, it doesn't rescue you — and you buy that time with CPU cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Containers, OOM and hibernation
&lt;/h2&gt;

&lt;p&gt;Three more details, all of which show up during the first crisis rather than the first setup.&lt;/p&gt;

&lt;p&gt;First, cgroup accounting — and here the two differ sharply. In both setups swap usage counts against &lt;code&gt;memory.swap.max&lt;/code&gt;, so the expectation that "I added compressed swap, my containers can breathe now" doesn't materialise unless you loosen the limits yourself.&lt;/p&gt;

&lt;p&gt;The real difference is in the memory held by the compressed pages. For zswap, cgroup v2 has dedicated knobs: &lt;code&gt;memory.zswap.current&lt;/code&gt; shows that group's zswap consumption, &lt;code&gt;memory.zswap.max&lt;/code&gt; is a hard limit, and &lt;code&gt;memory.zswap.writeback&lt;/code&gt; set to 0 disables writing to swap devices for that group (the setting is hierarchical — disable it above and it stays disabled below). zram has no such accounting: the RAM the device holds for compressed pages is spent at host level without any record of which container it was held for. On a container-dense machine that alone is an argument for zswap if you care about isolation and pressure signals.&lt;/p&gt;

&lt;p&gt;Second, OOM behaviour. Compressed swap delays memory exhaustion, it doesn't cancel it — and while it delays, the system burns CPU. If you want to catch memory pressure early and evict in a controlled way, the answer isn't compression but a pressure-based policy; the approach in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/systemd-oomd-ile-cgroup-v2-memory-pressure-runbooku/" rel="noopener noreferrer"&gt;my systemd-oomd cgroup v2 memory pressure runbook&lt;/a&gt; is complementary here. Compressed swap buys you time; policy decides what you do with it.&lt;/p&gt;

&lt;p&gt;Third, hibernation. The hibernation image has to be written to a persistent device, and a zram device living in RAM vanishes the moment power goes. If you set up zram on a laptop and still expect hibernate to work, you need a separate swap area on disk.&lt;/p&gt;

&lt;p&gt;These three are different faces of what I lived through in &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/ilk-oom-swap-fistirmasi-ve-katmanli-savunma/" rel="noopener noreferrer"&gt;my first OOM and swap storm&lt;/a&gt;: in memory management, turning a single knob rarely settles anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't decide without measuring
&lt;/h2&gt;

&lt;p&gt;The sneakiest thing about compressed swap is that whether it's working isn't obvious. After setup, the places to look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;awk '{print $1/$3}' /sys/block/zram0/mm_stat&lt;/code&gt; → the real compression ratio (&lt;code&gt;orig_data_size / mem_used_total&lt;/code&gt;). The field order in that file is &lt;code&gt;orig_data_size compr_data_size mem_used_total mem_limit mem_used_max same_pages pages_compacted huge_pages huge_pages_since&lt;/code&gt;. Track incompressible workloads with &lt;code&gt;huge_pages_since&lt;/code&gt;, which counts since setup, rather than the instantaneous &lt;code&gt;huge_pages&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -r . /sys/kernel/debug/zswap/&lt;/code&gt; → this is where zswap is measured; the documentation notes a debugfs interface providing statistics about pool size, the number of pages stored, and counters for why pages were rejected.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;swapon --show&lt;/code&gt; → which swap area is actually being used, and at what priority.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cat /proc/pressure/memory&lt;/code&gt; → did memory pressure really drop, or did it just move?&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;si&lt;/code&gt;/&lt;code&gt;so&lt;/code&gt; columns of &lt;code&gt;vmstat 1&lt;/code&gt; → the volume of page in/out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As a checklist: for zram, confirm the module package is installed; don't make &lt;code&gt;disksize&lt;/code&gt; larger than twice your RAM; reconsider &lt;code&gt;swappiness&lt;/code&gt; for compressed swap (persuading the kernel to be more willing can make sense when swap doesn't touch disk); for zswap, tune &lt;code&gt;max_pool_percent&lt;/code&gt; to your workload's real compression ratio; and on both sides, do a reboot drill before making the change permanent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rethinking swap
&lt;/h2&gt;

&lt;p&gt;Putting these two mechanisms side by side, what strikes me isn't their technical difference but that both are answers to the same question: when memory runs short, what gets sacrificed?&lt;/p&gt;

&lt;p&gt;Classic swap sacrifices the disk — latency explodes but the system stays up. zram and zswap sacrifice CPU instead, and in return keep latency far lower. Which is right depends on which resource is abundant in your system.&lt;/p&gt;

&lt;p&gt;So the question for your own setup is this: is your bottleneck really memory, or the disk latency that appears the moment memory gets tight? If it's the second, the fix may not be buying more RAM — sometimes an &lt;code&gt;echo 1&lt;/code&gt; is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/admin-guide/blockdev/zram.html" rel="noopener noreferrer"&gt;Linux kernel: zram — compressed RAM-based block devices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/admin-guide/mm/zswap.html" rel="noopener noreferrer"&gt;Linux kernel: zswap — a compressed cache for swap pages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/zram-generator" rel="noopener noreferrer"&gt;systemd zram-generator — zram swap setup at boot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/admin-guide/sysctl/vm.html" rel="noopener noreferrer"&gt;Linux kernel: virtual memory sysctl reference (swappiness)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>memory</category>
      <category>kernel</category>
      <category>performance</category>
    </item>
    <item>
      <title>Landlock: When a Program Jails Itself</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 05 Sep 2026 18:39:15 +0000</pubDate>
      <link>https://dev.to/merbayerp/landlock-when-a-program-jails-itself-1ag6</link>
      <guid>https://dev.to/merbayerp/landlock-when-a-program-jails-itself-1ag6</guid>
      <description>&lt;p&gt;When we want to limit what a program can do, our habit is to look in the same direction: at the administrator. We write an AppArmor profile, compile an SELinux policy, add &lt;code&gt;ProtectSystem=&lt;/code&gt; to a systemd unit. All of it works. But all of it shares one weakness — the party that sets the limit and the party that writes the restricted code are different, so the two drift apart over time. The application starts writing to a new directory and the profile stays behind; or the profile is far too wide and nobody notices.&lt;/p&gt;

&lt;p&gt;Landlock inverts that relationship. The process places the limit on itself: at startup it says "I will only read these directories and connect to this port", and from that moment on it cannot do more. No root privileges required, no policy file, and no way to undo it.&lt;/p&gt;

&lt;p&gt;The thesis of this article: Landlock's real novelty isn't being "one more sandbox", it's &lt;strong&gt;changing who owns the policy&lt;/strong&gt;. That change has a price — version negotiation is now the application's job, and the list of things it cannot restrict is too long to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Already installed on my server
&lt;/h2&gt;

&lt;p&gt;Landlock has been in the kernel since 5.13 and most current distributions ship it enabled — but not all of them. On my server running Ubuntu 24.04, the active security modules look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/kernel/security/lsm
&lt;span class="go"&gt;lockdown,capability,landlock,yama,apparmor
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AppArmor and Landlock run side by side on the same machine; neither replaces the other. The restrictions stack, and each layer can separately deny the access that falls to it.&lt;/p&gt;

&lt;p&gt;If you don't see &lt;code&gt;landlock&lt;/code&gt; in that list, you're not done yet. There are two separate switches: the module being compiled in (&lt;code&gt;CONFIG_SECURITY_LANDLOCK=y&lt;/code&gt;) and being present in the list enabled at boot (&lt;code&gt;CONFIG_LSM&lt;/code&gt;). If the second is missing — and on some distribution kernels the list ships as &lt;code&gt;lockdown,yama,integrity,apparmor&lt;/code&gt; — you need to add &lt;code&gt;lsm=landlock,...&lt;/code&gt; to the kernel command line. You can check from the kernel log, since Landlock announces itself when it comes up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zgrep &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s2"&gt;"^CONFIG_LSM="&lt;/span&gt; &lt;span class="s2"&gt;"/boot/config-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /proc/config.gz 2&amp;gt;/dev/null
dmesg | &lt;span class="nb"&gt;grep &lt;/span&gt;landlock &lt;span class="o"&gt;||&lt;/span&gt; journalctl &lt;span class="nt"&gt;-kb&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; landlock   &lt;span class="c"&gt;# "landlock: Up and running"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But which Landlock? Because there isn't a single "Landlock", there's a numbered series of capabilities. The way to ask for the version is, interestingly, a system call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
&lt;/span&gt;&lt;span class="go"&gt;import ctypes
libc = ctypes.CDLL(None, use_errno=True)
abi = libc.syscall(444, None, ctypes.c_size_t(0), ctypes.c_uint32(1))
print('ABI:', abi, '| errno:', ctypes.get_errno())"
ABI: 4 | errno: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(444 is the &lt;code&gt;landlock_create_ruleset&lt;/code&gt; syscall number on x86_64 and arm64; on failure the return value is -1 and &lt;code&gt;errno&lt;/code&gt; tells the two cases apart.)&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)&lt;/code&gt; call returns the highest supported ABI version. On my kernel the answer is 4. We'll get to what that means shortly; for now note this: the answer can be anything between 1 and 11, and the difference directly determines what your sandbox can and cannot restrict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism: three steps and a one-way door
&lt;/h2&gt;

&lt;p&gt;The flow is short. First you define a ruleset declaring which kinds of access it &lt;em&gt;handles&lt;/em&gt; — filesystem rights, and since ABI 4, TCP port rights. Then you add the hierarchies you allow: "this directory is readable", "this one is writable". In the final step, the process restricts itself with that ruleset.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtBc2sgZm9yIHRoZSBBQkkgdmVyc2lvbjxici8-TEFORExPQ0tfQ1JFQVRFX1JVTEVTRVRfVkVSU0lPTl0gLS0-IEJ7SXMgaXQgc3VwcG9ydGVkP30KICBCIC0tPnxFTk9TWVN8IFhbS2VybmVsIGhhcyBubyBMYW5kbG9jazxici8-YmVzdCBlZmZvcnQ6IGNvbnRpbnVlIHVucmVzdHJpY3RlZF0KICBCIC0tPnxFT1BOT1RTVVBQfCBYMltMYW5kbG9jayBpcyBkaXNhYmxlZDxici8-YmVzdCBlZmZvcnQ6IGNvbnRpbnVlIHVucmVzdHJpY3RlZF0KICBCIC0tPnxWZXJzaW9uIE58IENbQ3JlYXRlIHRoZSBydWxlc2V0PGJyLz5vbmx5IHJpZ2h0cyBzdXBwb3J0ZWQgYnkgTl0KICBDIC0tPiBEW0FkZCBhbGxvd2VkIGhpZXJhcmNoaWVzPGJyLz5maWxlIHBhdGhzIGFuZCBUQ1AgcG9ydHNdCiAgRCAtLT4gRVtwcmN0bCBQUl9TRVRfTk9fTkVXX1BSSVZTXQogIEUgLS0-IEZbbGFuZGxvY2tfcmVzdHJpY3Rfc2VsZl0KICBGIC0tPiBHW05vIHdheSBiYWNrIGZyb20gaGVyZTxici8-Y2hpbGQgcHJvY2Vzc2VzIGluaGVyaXQgdGhlIHJlc3RyaWN0aW9uXQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtBc2sgZm9yIHRoZSBBQkkgdmVyc2lvbjxici8-TEFORExPQ0tfQ1JFQVRFX1JVTEVTRVRfVkVSU0lPTl0gLS0-IEJ7SXMgaXQgc3VwcG9ydGVkP30KICBCIC0tPnxFTk9TWVN8IFhbS2VybmVsIGhhcyBubyBMYW5kbG9jazxici8-YmVzdCBlZmZvcnQ6IGNvbnRpbnVlIHVucmVzdHJpY3RlZF0KICBCIC0tPnxFT1BOT1RTVVBQfCBYMltMYW5kbG9jayBpcyBkaXNhYmxlZDxici8-YmVzdCBlZmZvcnQ6IGNvbnRpbnVlIHVucmVzdHJpY3RlZF0KICBCIC0tPnxWZXJzaW9uIE58IENbQ3JlYXRlIHRoZSBydWxlc2V0PGJyLz5vbmx5IHJpZ2h0cyBzdXBwb3J0ZWQgYnkgTl0KICBDIC0tPiBEW0FkZCBhbGxvd2VkIGhpZXJhcmNoaWVzPGJyLz5maWxlIHBhdGhzIGFuZCBUQ1AgcG9ydHNdCiAgRCAtLT4gRVtwcmN0bCBQUl9TRVRfTk9fTkVXX1BSSVZTXQogIEUgLS0-IEZbbGFuZGxvY2tfcmVzdHJpY3Rfc2VsZl0KICBGIC0tPiBHW05vIHdheSBiYWNrIGZyb20gaGVyZTxici8-Y2hpbGQgcHJvY2Vzc2VzIGluaGVyaXQgdGhlIHJlc3RyaWN0aW9uXQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="889" height="995"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The third step has two conditions. First: for unprivileged processes, the &lt;code&gt;no_new_privs&lt;/code&gt; attribute must be set. Processes carrying &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt; can skip it, but the documentation advises against skipping — a sandboxed process could still execute a SUID binary, and that binary would run with elevated privileges inside a Landlock domain it doesn't expect, turning it into a confused deputy.&lt;/p&gt;

&lt;p&gt;Second, and more importantly: this door is one-way. The documentation is explicit — once a thread is landlocked there is no way to remove its policy, only to add more restrictions. Every new thread born via &lt;code&gt;clone(2)&lt;/code&gt; inherits the restriction from its parent, and it survives &lt;code&gt;execve&lt;/code&gt;. So if a shell landlocks itself and then invokes a compiler, the compiler is born into the same jail.&lt;/p&gt;

&lt;p&gt;The nicest practical consequence: the sandbox no longer lives in a file shipped with the distribution, it lives in the application's own code. It can't be forgotten, it doesn't fall behind on updates, and there's no "why is the profile disabled" conversation.&lt;/p&gt;

&lt;p&gt;There is one boundary: at most 16 stacked rulesets, and once that limit is reached &lt;code&gt;landlock_restrict_self()&lt;/code&gt; returns &lt;code&gt;E2BIG&lt;/code&gt;. That's why the documentation recommends building the ruleset carefully, once in a thread's lifetime — especially for shells and container managers that launch other applications.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code itself: three system calls
&lt;/h2&gt;

&lt;p&gt;To make this concrete, the skeleton from the kernel documentation's tutorial is enough. First, the ruleset is defined by explicitly listing the access types to be &lt;em&gt;handled&lt;/em&gt;. Landlock denies those types by default; an access type you don't declare isn't checked at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;landlock_ruleset_attr&lt;/span&gt; &lt;span class="n"&gt;ruleset_attr&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="n"&gt;handled_access_fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_EXECUTE&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_WRITE_FILE&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_READ_FILE&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_READ_DIR&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_REMOVE_DIR&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_REMOVE_FILE&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_MAKE_DIR&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_MAKE_REG&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_REFER&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_TRUNCATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handled_access_net&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;LANDLOCK_ACCESS_NET_BIND_TCP&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;LANDLOCK_ACCESS_NET_CONNECT_TCP&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;That attribute struct becomes a ruleset descriptor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ruleset_fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;landlock_create_ruleset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ruleset_attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleset_attr&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the allowed hierarchy is added. The striking detail is that the directory is opened with &lt;code&gt;O_PATH&lt;/code&gt; and handed over as a file descriptor — the rule is defined over an opened object, not a path string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;landlock_path_beneath_attr&lt;/span&gt; &lt;span class="n"&gt;path_beneath&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="n"&gt;allowed_access&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_EXECUTE&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
                      &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_READ_FILE&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
                      &lt;span class="n"&gt;LANDLOCK_ACCESS_FS_READ_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;path_beneath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed_access&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;=&lt;/span&gt; &lt;span class="n"&gt;ruleset_attr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handled_access_fs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;path_beneath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/usr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;O_PATH&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;O_CLOEXEC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;landlock_add_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleset_fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LANDLOCK_RULE_PATH_BENEATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;path_beneath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final step closes the door:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;prctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PR_SET_NO_NEW_PRIVS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;landlock_restrict_self&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleset_fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The masking line is no accident: allowed rights must be a subset of handled rights. When you trim &lt;code&gt;handled_access_fs&lt;/code&gt; according to the ABI, that mask narrows the rule automatically. The documentation applies the same trimming to &lt;code&gt;landlock_restrict_self()&lt;/code&gt; flags — logging flags are removed below ABI 7, the multithreaded enforcement flag below ABI 8.&lt;/p&gt;

&lt;p&gt;The filesystem rights list being this granular is deliberate too. Reading and listing a directory, creating a file and creating a directory, truncating and writing all have separate rights. That level of detail looks tiring at first, but it's the only way to keep a restriction genuinely narrow.&lt;/p&gt;

&lt;h2&gt;
  
  
  ABI negotiation is now your job
&lt;/h2&gt;

&lt;p&gt;This is Landlock's most misunderstood aspect. Your code doesn't know which kernel it will run on, and Landlock deliberately builds backward compatibility on "explicitly declared rights" rather than "silently getting stricter". Making the handled access rights explicit creates a clear contract between kernel and user space, so a system update can't tighten the sandbox unexpectedly and break the application.&lt;/p&gt;

&lt;p&gt;The price is that the application itself has to climb the version ladder. ABI 2 brought control over renaming and linking across directories (&lt;code&gt;LANDLOCK_ACCESS_FS_REFER&lt;/code&gt;), ABI 3 file truncation (&lt;code&gt;LANDLOCK_ACCESS_FS_TRUNCATE&lt;/code&gt;), ABI 4 the TCP side (&lt;code&gt;LANDLOCK_ACCESS_NET_BIND_TCP&lt;/code&gt;, &lt;code&gt;LANDLOCK_ACCESS_NET_CONNECT_TCP&lt;/code&gt;), ABI 5 &lt;code&gt;ioctl&lt;/code&gt; control on device files, ABI 6 scoping restrictions for abstract unix sockets and signals, ABI 7 the logging flags, ABI 8 multithreaded enforcement, ABI 9 restrictions on connecting to pathname unix sockets (&lt;code&gt;LANDLOCK_ACCESS_FS_RESOLVE_UNIX&lt;/code&gt;), ABI 10 the UDP rights plus per-rule log suppression, and ABI 11 the flag that folds &lt;code&gt;no_new_privs&lt;/code&gt; into the enforcement call itself.&lt;/p&gt;

&lt;p&gt;Now back to my server: ABI 4. A sandbox I write there can restrict the filesystem and TCP connections, and can't touch UDP at all.&lt;/p&gt;

&lt;p&gt;But the real boundary isn't UDP, and it deserves to be said plainly: &lt;strong&gt;Landlock sees the network at port level, not at destination level.&lt;/strong&gt; The rule structure (&lt;code&gt;struct landlock_net_port_attr&lt;/code&gt;) carries exactly one field — the port. So when you say "you may connect to TCP 443", the process may connect to port 443 on every server in the world. If you want to close the exfiltration path, Landlock alone won't do it; destination-based egress control is still a job for nftables, an egress proxy, or a network namespace. The missing UDP is only a sub-heading of that picture — and ABI 10 doesn't solve it at the address level either, it merely makes UDP controllable by port.&lt;/p&gt;

&lt;p&gt;The most painful rung of the ladder is threads. Up to ABI 8 — and even at ABI 8 when the relevant flag isn't used — &lt;code&gt;landlock_restrict_self()&lt;/code&gt; enforces the policy only for the calling thread and its children; sibling and parent threads stay outside. The documentation's warning is exactly this: the call is equivalent to "I jailed the process" only if the process is single-threaded. Forget it in a program written with Go, Java or any pooled runtime and you'll have jailed one thread while believing you jailed yourself. The multithreaded enforcement flag (&lt;code&gt;LANDLOCK_RESTRICT_SELF_TSYNC&lt;/code&gt;) arrived with ABI 8; on older kernels the only way out is to apply the restriction at the very start of the program, before any extra thread is born.&lt;/p&gt;

&lt;p&gt;The recommended path here is "best effort": detect the ABI version at runtime, use only the supported subset of rights, and protect users as much as possible whatever kernel they run. The error returned when the query fails also guides you: &lt;code&gt;ENOSYS&lt;/code&gt; means the kernel doesn't support Landlock at all, &lt;code&gt;EOPNOTSUPP&lt;/code&gt; means it has been disabled.&lt;/p&gt;

&lt;p&gt;The engineering decision is yours: what will you do on a missing ABI? Silently continuing unrestricted is what most desktop tools choose. On the server side I think the right move is at least to print a warning at startup saying which protections aren't active. &lt;/p&gt;

&lt;h2&gt;
  
  
  How will you see a denied access?
&lt;/h2&gt;

&lt;p&gt;When writing a sandbox, the real time sink isn't setting up the policy, it's answering "why doesn't this work". This side of Landlock matured late and in two steps. First ABI 7: the &lt;code&gt;LANDLOCK_RESTRICT_SELF_LOG_*&lt;/code&gt; flags that govern whether denied accesses produce audit records. Then ABI 10: the &lt;code&gt;LANDLOCK_ADD_RULE_QUIET&lt;/code&gt; flag together with the ruleset's &lt;code&gt;quiet_access_fs&lt;/code&gt;/&lt;code&gt;quiet_access_net&lt;/code&gt; fields, which selectively suppress logs for denied accesses on specific objects. Since suppression only applies to the layer that denies the access, a sandboxed program can't use it to hide its own violations. Landlock tracepoints are unaffected by these flags either way, so they remain a separate observability channel for debugging.&lt;/p&gt;

&lt;p&gt;None of that exists on my ABI 4. So on an older kernel, debugging falls back to the classic method: hunt &lt;code&gt;EACCES&lt;/code&gt; with &lt;code&gt;strace&lt;/code&gt; and loosen the restriction step by step. Measuring which paths the application actually touches before switching the sandbox on in production isn't just a good habit on older kernels, it's a requirement.&lt;/p&gt;

&lt;p&gt;One more caveat from the documentation: there is a &lt;code&gt;LANDLOCK_CREATE_RULESET_ERRATA&lt;/code&gt; query for checking which bugs are fixed at runtime, but the docs say the vast majority of applications should not look at it — it complicates the code and can reduce protection when misused. In security APIs, "more configuration knowledge" doesn't always mean better security.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cannot restrict
&lt;/h2&gt;

&lt;p&gt;Landlock is not a container and doesn't try to be. The "current limitations" section of the documentation lists them honestly; these are the ones that will hurt in production.&lt;/p&gt;

&lt;p&gt;A sandboxed thread cannot modify filesystem topology: &lt;code&gt;mount(2)&lt;/code&gt; and &lt;code&gt;pivot_root(2)&lt;/code&gt; are denied. But &lt;code&gt;chroot(2)&lt;/code&gt; is not refused — changing the root is still possible.&lt;/p&gt;

&lt;p&gt;Objects that don't come from a user-visible filesystem (pipes, sockets) can still be reached through &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/fd/*&lt;/code&gt; yet cannot be explicitly restricted; the same goes for special kernel filesystems such as nsfs. What steps in for those is the ptrace restrictions: access to such sensitive &lt;code&gt;/proc&lt;/code&gt; files is automatically limited according to domain hierarchies.&lt;/p&gt;

&lt;p&gt;The item that traps most people is already-open file descriptors. Landlock evaluates access at open time; a descriptor opened before the jail stays in your hands and can even be passed between processes, carrying its Landlock properties with it. &lt;code&gt;LANDLOCK_ACCESS_FS_IOCTL_DEV&lt;/code&gt; says so explicitly: it applies only to newly opened device files, and pre-existing descriptors like &lt;code&gt;stdin&lt;/code&gt;, &lt;code&gt;stdout&lt;/code&gt; and &lt;code&gt;stderr&lt;/code&gt; are unaffected.&lt;/p&gt;

&lt;p&gt;That behaviour is both a trap and a tool. A trap, because while you say "I restricted everything" an old descriptor may remain in your hands. A tool, because opening the file you need before landlocking and keeping the descriptor is one way to preserve access to it. Just know the limits: what you keep is that one open file, every later &lt;code&gt;openat&lt;/code&gt; in the same directory is checked again, the rights that stick to a descriptor are limited to &lt;code&gt;LANDLOCK_ACCESS_FS_TRUNCATE&lt;/code&gt; and &lt;code&gt;LANDLOCK_ACCESS_FS_IOCTL_DEV&lt;/code&gt;, and when a log file is rotated your descriptor keeps writing to the old inode — you'll be logging to the wrong file without noticing.&lt;/p&gt;

&lt;p&gt;There's also a list of operations that simply aren't covered: calls like &lt;code&gt;chdir&lt;/code&gt;, &lt;code&gt;stat&lt;/code&gt;, &lt;code&gt;flock&lt;/code&gt;, &lt;code&gt;chmod&lt;/code&gt;, &lt;code&gt;chown&lt;/code&gt;, &lt;code&gt;setxattr&lt;/code&gt;, &lt;code&gt;utime&lt;/code&gt;, &lt;code&gt;fcntl&lt;/code&gt; and &lt;code&gt;access&lt;/code&gt; fall outside Landlock's scope. So if you think "it can't do anything in this directory", quite a lot of metadata work remains possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it helps and where it doesn't
&lt;/h2&gt;

&lt;p&gt;What accelerated Landlock's rise over the past year is, I think, coding agents. You want a tool that runs shell commands on your machine, but you don't want it reading &lt;code&gt;~/.ssh&lt;/code&gt;. On the filesystem side Landlock gives you exactly that; on the network side, remember the port restriction above — you can say "it only reads these directories", you can't say "it only connects to this server". A container is heavy and full of friction here; Landlock works inside the process itself, without asking for extra privileges. The &lt;code&gt;go-landlock&lt;/code&gt; library and command-line wrappers like &lt;code&gt;landrun&lt;/code&gt; exist because of that need.&lt;/p&gt;

&lt;p&gt;On the server side, put Landlock on top of admin-side hardening, not in place of it. Restrictions in the unit file are still your first line of defence; the &lt;code&gt;ProtectSystem=&lt;/code&gt; family I wrote about in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/systemd-service-sandbox-ile-sertlestirme/" rel="noopener noreferrer"&gt;hardening systemd services with the service sandbox&lt;/a&gt; is the only practical route for third-party services whose code you can't change. Landlock enters where you do own the code: the application knows its own needs best.&lt;/p&gt;

&lt;p&gt;To see the two working together, a glance at the LSM list on my server is enough: AppArmor and Landlock sit right next to each other.&lt;/p&gt;

&lt;p&gt;There's also a trap for services running under systemd. In a unit that says &lt;code&gt;SystemCallFilter=@system-service&lt;/code&gt;, Landlock's system calls get caught by the filter, because they live in systemd's &lt;code&gt;@sandbox&lt;/code&gt; group and &lt;code&gt;@system-service&lt;/code&gt; doesn't include it. If a service trying to jail itself fails unexpectedly, check the unit's syscall filter first — the fix is &lt;code&gt;SystemCallFilter=@system-service @sandbox&lt;/code&gt;. The same logic applies to seccomp: Landlock and seccomp aren't alternatives, a typical sandbox uses both, and a narrow seccomp filter can accidentally block Landlock itself.&lt;/p&gt;

&lt;p&gt;So where doesn't it help? It doesn't if you can't change the code — then you either write a wrapper or return to admin-side tooling. If you want filtering at the system call level you're holding the wrong tool; that's seccomp's job. And if you want an isolated root filesystem you need containers; Landlock doesn't give you a separate world, it gives you a narrower view of the same one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;First check that it's enabled (&lt;code&gt;/sys/kernel/security/lsm&lt;/code&gt;, &lt;code&gt;dmesg | grep landlock&lt;/code&gt;), then measure the ABI version. The lowest version in your fleet is your real level of protection.&lt;/li&gt;
&lt;li&gt;Declare rights explicitly; an access type you don't handle isn't restricted.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;no_new_privs&lt;/code&gt; — even when you have the privileges to skip it.&lt;/li&gt;
&lt;li&gt;Build the ruleset once; don't spend the 16-layer limit on other sandboxes.&lt;/li&gt;
&lt;li&gt;If you have a multithreaded runtime, apply the restriction at the very start: below ABI 8, enforcement only covers the calling thread and its children.&lt;/li&gt;
&lt;li&gt;Open the descriptors you need before the jail; path-based access closes afterwards — but think through file rotation.&lt;/li&gt;
&lt;li&gt;Network control is port-level: if you need destination control, plan for nftables or an egress proxy; UDP isn't covered at all before ABI 10.&lt;/li&gt;
&lt;li&gt;Restrictions can't be undone: on failure your only option is restarting the process, so design for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who writes the policy?
&lt;/h2&gt;

&lt;p&gt;Looking at Landlock, what caught my attention more than the technical detail was the answer to a question: who should place a restriction? The classic answer was the administrator, because security was operations' job. Landlock's proposed answer is different — let the restriction be placed by the party that knows best what the program does, which is the code.&lt;/p&gt;

&lt;p&gt;That means responsibility shifts to the developer, and frankly not every team is ready for it. But the reward is large: the sandbox becomes a feature of the application rather than an add-on of the deployment. It travels with versions, goes through code review, and is protected by tests.&lt;/p&gt;

&lt;p&gt;The question for your own setup follows from that: for how many of your services does the policy live in their own repository, and for how many does it live in a file on a server? If the second group is large, drift is accumulating there — and the first party to notice drift is usually an attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/userspace-api/landlock.html" rel="noopener noreferrer"&gt;Linux kernel: Landlock userspace API and tutorial&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/security/landlock.html" rel="noopener noreferrer"&gt;Linux kernel: Landlock LSM design notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/landlock-lsm/go-landlock" rel="noopener noreferrer"&gt;go-landlock — a Go library for Landlock&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/systemd/blob/v254/NEWS" rel="noopener noreferrer"&gt;systemd NEWS v254 — the @sandbox syscall filter group (seccomp and Landlock calls)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>kernel</category>
      <category>sandbox</category>
    </item>
    <item>
      <title>MPTCP: Enabled in the Kernel, Used by Nobody</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 05 Sep 2026 18:08:49 +0000</pubDate>
      <link>https://dev.to/merbayerp/mptcp-enabled-in-the-kernel-used-by-nobody-21d4</link>
      <guid>https://dev.to/merbayerp/mptcp-enabled-in-the-kernel-used-by-nobody-21d4</guid>
      <description>&lt;p&gt;The first time you hear about Multipath TCP it sounds too good: let a single TCP connection flow over several paths, don't drop the session when a link dies, and if you have two links, use both. Anyone with fibre and an LTE backup has had that thought. And the feature isn't in some distant future — it is in your kernel right now, enabled.&lt;/p&gt;

&lt;p&gt;That is exactly what I found when I checked my own server. And right after it, the thesis of this article: &lt;strong&gt;being enabled does not mean being used.&lt;/strong&gt; In MPTCP, &lt;code&gt;enabled=1&lt;/code&gt; isn't a starting line, it's just the first door standing ajar. Behind it are three more doors that must open in order, and in practice the place people get stuck is almost never the kernel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enabled on my server; never used
&lt;/h2&gt;

&lt;p&gt;Here's the state on my server running Ubuntu 24.04 and kernel 6.8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sysctl net.mptcp
&lt;span class="go"&gt;net.mptcp.add_addr_timeout = 120
net.mptcp.allow_join_initial_addr_port = 1
net.mptcp.checksum_enabled = 0
net.mptcp.close_timeout = 60
net.mptcp.enabled = 1
net.mptcp.pm_type = 0
net.mptcp.scheduler = default
net.mptcp.stale_loss_cnt = 4

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip mptcp endpoint show
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip mptcp limits show
&lt;span class="go"&gt;add_addr_accepted 0 subflows 2

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nstat &lt;span class="nt"&gt;-az&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; MPTcpExt
&lt;span class="go"&gt;63
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;nstat &lt;span class="nt"&gt;-az&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;MPTcpExtMPCapableSYNTX
&lt;span class="go"&gt;MPTcpExtMPCapableSYNTX          0                  0.0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel supports MPTCP, it's on, it stands ready to keep 63 separate counters — and every one of them is zero. Not a single MPTCP connection attempt has ever left this machine. On most distributions the picture is identical; this table is the rule itself.&lt;/p&gt;

&lt;p&gt;Why zero? Because the kernel being enabled is only one of four conditions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtuZXQubXB0Y3AuZW5hYmxlZCA9IDE8YnIvPmtlcm5lbCBpcyByZWFkeV0gLS0-IEJ7V2FzIHRoZSBzb2NrZXQgb3BlbmVkPGJyLz53aXRoIElQUFJPVE9fTVBUQ1A_fQogIEIgLS0-fE5vfCBYW1BsYWluIFRDUDxici8-Y291bnRlcnMgc3RheSBhdCB6ZXJvXQogIEIgLS0-fFllc3wgQ3tBcmUgcGF0aCBtYW5hZ2VyPGJyLz5lbmRwb2ludHMgYW5kIGxpbWl0cyBzZXQ_fQogIEMgLS0-fE5vfCBZW1NpbmdsZSBzdWJmbG93PGJyLz5NUFRDUCBidXQgbm90IG11bHRpcGF0aF0KICBDIC0tPnxZZXN8IER7RG8gdGhlIHBlZXIgYW5kIHRoZSBib3hlcyBpbiBiZXR3ZWVuPGJyLz5sZXQgTVBUQ1Agb3B0aW9ucyB0aHJvdWdoP30KICBEIC0tPnxOb3wgWltGYWxsYmFjazogcGxhaW4gVENQXQogIEQgLS0-fFllc3wgRltNdWx0aXBhdGggY29ubmVjdGlvbjxici8-YWRkaXRpb25hbCBzdWJmbG93cyBhcmUgY3JlYXRlZF0%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtuZXQubXB0Y3AuZW5hYmxlZCA9IDE8YnIvPmtlcm5lbCBpcyByZWFkeV0gLS0-IEJ7V2FzIHRoZSBzb2NrZXQgb3BlbmVkPGJyLz53aXRoIElQUFJPVE9fTVBUQ1A_fQogIEIgLS0-fE5vfCBYW1BsYWluIFRDUDxici8-Y291bnRlcnMgc3RheSBhdCB6ZXJvXQogIEIgLS0-fFllc3wgQ3tBcmUgcGF0aCBtYW5hZ2VyPGJyLz5lbmRwb2ludHMgYW5kIGxpbWl0cyBzZXQ_fQogIEMgLS0-fE5vfCBZW1NpbmdsZSBzdWJmbG93PGJyLz5NUFRDUCBidXQgbm90IG11bHRpcGF0aF0KICBDIC0tPnxZZXN8IER7RG8gdGhlIHBlZXIgYW5kIHRoZSBib3hlcyBpbiBiZXR3ZWVuPGJyLz5sZXQgTVBUQ1Agb3B0aW9ucyB0aHJvdWdoP30KICBEIC0tPnxOb3wgWltGYWxsYmFjazogcGxhaW4gVENQXQogIEQgLS0-fFllc3wgRltNdWx0aXBhdGggY29ubmVjdGlvbjxici8-YWRkaXRpb25hbCBzdWJmbG93cyBhcmUgY3JlYXRlZF0%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="837" height="1305"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The first door: the application itself
&lt;/h2&gt;

&lt;p&gt;MPTCP is not a transparent layer that slots in where TCP was. The application has to ask for it when it opens the socket; in kernel documentation terms, that means creating the socket with &lt;code&gt;IPPROTO_MPTCP&lt;/code&gt; (value 262):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AF_INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SOCK_STREAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IPPROTO_MPTCP&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even the errors give you the diagnosis: kernels older than 5.6 return &lt;code&gt;EINVAL&lt;/code&gt;, a kernel built without MPTCP gives &lt;code&gt;EPROTONOSUPPORT&lt;/code&gt;, and if MPTCP was turned off via &lt;code&gt;net.mptcp.enabled&lt;/code&gt; you get &lt;code&gt;ENOPROTOOPT&lt;/code&gt;. An application can also ask &lt;code&gt;TCP_IS_MPTCP&lt;/code&gt; whether the connection really ended up as MPTCP.&lt;/p&gt;

&lt;p&gt;The operational meaning is inconvenient: if the service you run doesn't carry that line in its source, flipping the sysctl changes nothing. The kernel documentation lists several techniques for forcing an application into MPTCP — &lt;code&gt;LD_PRELOAD&lt;/code&gt; (mptcpize), eBPF (mptcpify), SystemTAP, &lt;code&gt;GODEBUG&lt;/code&gt; on the Go side — and these four are the ones that matter in practice.&lt;/p&gt;

&lt;p&gt;The first is systemd's socket activation. In &lt;code&gt;.socket&lt;/code&gt; units, &lt;code&gt;SocketProtocol=&lt;/code&gt; takes &lt;code&gt;udplite&lt;/code&gt;, &lt;code&gt;sctp&lt;/code&gt; or &lt;code&gt;mptcp&lt;/code&gt;, and the last one opens the socket with &lt;code&gt;IPPROTO_MPTCP&lt;/code&gt;. Mind the version trap: this support arrived in systemd 257, and my server on 255 doesn't have it. Why collecting &lt;code&gt;systemctl --version&lt;/code&gt; across the fleet is the first step of a runbook is something we also discussed &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/systemd-soft-reboot-ile-kesintiyi-kisaltmak/" rel="noopener noreferrer"&gt;in the previous article, about soft-reboot&lt;/a&gt;; the same discipline applies here.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;mptcpize&lt;/code&gt;, which ships with the &lt;code&gt;mptcpd&lt;/code&gt; package. In its man page's own words, it is a program that enables MPTCP on existing legacy services: &lt;code&gt;mptcpize run prog&lt;/code&gt; runs the target program forcing MPTCP socket usage instead of TCP, while &lt;code&gt;mptcpize enable unit&lt;/code&gt; updates the systemd unit file so the service runs under that launcher.&lt;/p&gt;

&lt;p&gt;The third is changing the application. The option that sounds like the most work is actually the cleanest one when the service is yours: a one-line protocol change.&lt;/p&gt;

&lt;p&gt;The fourth partly refutes my own thesis: Go. The language added MPTCP support in 1.21, but only used it if the application explicitly asked. Go 1.24 changed the default — &lt;code&gt;GODEBUG=multipathtcp&lt;/code&gt; is now &lt;code&gt;2&lt;/code&gt;, meaning MPTCP is enabled by default on listeners, and you need &lt;code&gt;0&lt;/code&gt; to get the old behaviour back. So a server built with Go 1.24 or later may be listening with MPTCP without anyone doing anything. "Nobody uses it" is increasingly true only of the client side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second door: path manager, endpoints and limits
&lt;/h2&gt;

&lt;p&gt;Say the socket was opened with MPTCP. That alone still doesn't give you multiple paths; for additional subflows to be created, the path manager has to know what to do.&lt;/p&gt;

&lt;p&gt;There are two kinds: the in-kernel manager applies the same rules to all connections and is configured with &lt;code&gt;ip mptcp endpoint&lt;/code&gt;, while the userspace manager (mptcpd) lets you write per-connection rules. One note on how current this is: the &lt;code&gt;net.mptcp.pm_type&lt;/code&gt; sysctl that selects between them is deprecated as of 6.15 and replaced by &lt;code&gt;net.mptcp.path_manager&lt;/code&gt;. My 6.8 only has the old one — the name you read in the docs may not exist on your kernel.&lt;/p&gt;

&lt;p&gt;The endpoint flags are the heart of it. &lt;code&gt;signal&lt;/code&gt; means the address will be announced to the peer with an ADD_ADDR option; in the man page's words, a server would typically be responsible for this. &lt;code&gt;subflow&lt;/code&gt; means that, if the limits allow, an additional subflow will be created using this address as the source after the connection is established; a client would typically do this. &lt;code&gt;backup&lt;/code&gt; marks a subflow as backup: the remote peer only sends data on it when all non-backup subflows are unavailable. &lt;code&gt;fullmesh&lt;/code&gt; tries to create a subflow towards every address the peer announces.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;laminar&lt;/code&gt; flag was added to that list recently: the patch landed on 26 September 2025 and shipped in 6.18. Its job is to create subflows towards the addresses the peer announces without leaving source address selection to the routing tables, which makes configuration easier when you don't know the announced address in advance; &lt;code&gt;fullmesh&lt;/code&gt; takes precedence over it. The help output of iproute2 6.1 on my server still lists &lt;code&gt;signal | subflow | backup | fullmesh&lt;/code&gt;, so pay attention to which version's documentation you're reading.&lt;/p&gt;

&lt;p&gt;Then come the limits, and here's the real surprise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ip mptcp limits show
&lt;span class="go"&gt;add_addr_accepted 0 subflows 2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;add_addr_accepted 0&lt;/code&gt; answers the question "how many addresses will I accept if the peer announces some?", and the default is zero. So even if your server announces addresses properly, nothing happens on the client side. Most people who say "I enabled MPTCP but it doesn't work" are standing exactly here; there is nothing wrong with the kernel, the policy is empty.&lt;/p&gt;

&lt;p&gt;One more detail: passing &lt;code&gt;dev&lt;/code&gt; with the interface name when adding an endpoint matters. The man page warns about it explicitly — without that binding, you may need extra IP rules and routes for packets from that source address to reach the right interface. On a multi-link machine, skipping this means the second subflow tries to leave over the first link, and you end up with one path wearing two names.&lt;/p&gt;

&lt;p&gt;Concretely, the simplest setup between a server with two addresses and a client on a single uplink looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# server: announce the second address to the peer&lt;/span&gt;
ip mptcp endpoint add 203.0.113.10 dev eth1 signal
ip mptcp limits &lt;span class="nb"&gt;set &lt;/span&gt;add_addr_accepted 1 subflows 2

&lt;span class="c"&gt;# client: allow an extra subflow towards the announced address&lt;/span&gt;
ip mptcp endpoint add 192.0.2.20 dev wwan0 subflow
ip mptcp limits &lt;span class="nb"&gt;set &lt;/span&gt;add_addr_accepted 1 subflows 2

&lt;span class="c"&gt;# verification on both sides&lt;/span&gt;
ip mptcp endpoint show
ip mptcp limits show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asymmetry is deliberate: the announcing side uses &lt;code&gt;signal&lt;/code&gt;, the side that creates the subflow uses &lt;code&gt;subflow&lt;/code&gt;. Forget to lift the limits off zero on either side and every command returns successfully while nothing happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens on the wire
&lt;/h2&gt;

&lt;p&gt;To see why these doors are so brittle, look at the handshake; RFC 8684 describes the mechanism plainly.&lt;/p&gt;

&lt;p&gt;The first connection opens with the &lt;code&gt;MP_CAPABLE&lt;/code&gt; option: the client states its intent in the SYN, the server sends its key in the SYN/ACK, and the client sends both keys together in the third packet. Those keys are never sent in the clear again; each end hashes its key with SHA-256 to produce a 32-bit token, and the connection is identified by that token from then on.&lt;/p&gt;

&lt;p&gt;Second and subsequent paths join with &lt;code&gt;MP_JOIN&lt;/code&gt;. The new subflow's SYN carries the token that answers "which connection am I joining", along with a random nonce; the peer replies with an HMAC derived from the exchanged keys. So an additional path is authenticated with the keys from the initial handshake, which is what stops an off-path attacker from adding a subflow to your session. Don't expect more than that: the keys travel in the clear during the initial handshake, and the goal the RFC states is for MPTCP's security to be "no worse" than today's TCP. Against someone who can observe the path, your guarantee is the same as plain TCP's.&lt;/p&gt;

&lt;p&gt;Address announcements travel in &lt;code&gt;ADD_ADDR&lt;/code&gt;, and those are signed with an HMAC too. The use case the RFC highlights belongs squarely to our world: when a NAT prevents setup in one direction, an address is announced so the other side can connect instead.&lt;/p&gt;

&lt;p&gt;The mechanism that stitches data across paths is the &lt;code&gt;DSS&lt;/code&gt; option, which maps subflow-level sequence numbers onto connection-level ones. It has an optional checksum whose purpose is interesting: detecting whether a middlebox unaware of MPTCP has adjusted the payload. On Linux it is off by default (&lt;code&gt;net.mptcp.checksum_enabled = 0&lt;/code&gt;), a trade made in favour of performance. The RFC is blunt here: without checksumming, corrupt data may be delivered to the application if a middlebox alters segment boundaries or content, so checksumming is recommended unless you know the path contains no such devices. If you're speaking MPTCP over an internet path you don't control, don't skip that line.&lt;/p&gt;

&lt;p&gt;And the fallback rule: if the SYN carries &lt;code&gt;MP_CAPABLE&lt;/code&gt; but the SYN/ACK doesn't, the peer is treated as incapable; if the option is missing from the third packet, the session must fall back to plain single-path TCP. That strictness isn't a shortcoming — it is a deliberate choice for compatibility with middleboxes that drop TCP options.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third door: the peer and the boxes in between
&lt;/h2&gt;

&lt;p&gt;MPTCP requires end-to-end agreement. If the far end or any middlebox in between doesn't support it, the documentation says the connection is "downgraded" to plain TCP and continues on a single path. The application doesn't even notice — which is good design, but if you don't measure it you'll be fooling yourself.&lt;/p&gt;

&lt;p&gt;The kernel is defensive about this — as long as it's new enough. &lt;code&gt;net.mptcp.blackhole_timeout&lt;/code&gt; (3600 seconds by default) disables MPTCP on active sockets for a while when a firewall blackhole is detected, and the period grows exponentially if the problem recurs; &lt;code&gt;net.mptcp.syn_retrans_before_tcp_fallback&lt;/code&gt; says how many SYN attempts carrying MPTCP options are made before falling back to TCP, defaulting to 2.&lt;/p&gt;

&lt;p&gt;Now look again at my &lt;code&gt;sysctl net.mptcp&lt;/code&gt; output above: neither of them is there. Blackhole detection arrived in 6.12 and the SYN retransmission knob in 6.14, so my 6.8 has neither reflex; &lt;code&gt;path_manager&lt;/code&gt; is 6.15's work. The first thing to run when reading an MPTCP article is &lt;code&gt;uname -r&lt;/code&gt; — the documentation doesn't always describe your kernel.&lt;/p&gt;

&lt;p&gt;Knowing the reflex matters because on 6.12 and later, if your test once traversed a bad path, MPTCP staying off in later attempts may not be a configuration mistake. The place to look then is the counters: &lt;code&gt;MPTcpExtBlackhole&lt;/code&gt; and &lt;code&gt;MPTcpExtMPCapableSYNTXDisabled&lt;/code&gt; exist exactly for this. Fiddling with settings before measuring means chasing a bug that isn't there.&lt;/p&gt;

&lt;p&gt;On the firewall side, expectations need correcting too. An additional subflow is a new TCP connection as far as the network is concerned: a different source address, usually a different interface. If your rule set only knows the address the first connection came from, the second path dies before it is born. &lt;code&gt;net.mptcp.allow_join_initial_addr_port&lt;/code&gt; defaults to 1 and lets the peer send join requests to the address and port used by the initial subflow, which makes the narrowest scenario — no extra ports opened — possible. If the announcement goes unacknowledged, &lt;code&gt;net.mptcp.add_addr_timeout&lt;/code&gt; steps in: the value (120 seconds by default) is the maximum retransmission timeout for ADD_ADDR, while the actual interval is derived from the connection's estimated round-trip time; set it to zero and it is never retried.&lt;/p&gt;

&lt;p&gt;There's a sneakier obstacle on the server side, and it usually shows up on the first attempt: because MP_JOIN arrives with a new four-tuple, an L4 load balancer or ECMP in front may well send it to a different backend. That machine doesn't hold the token, so the join is refused and the &lt;code&gt;MPTcpExtMPJoinNoTokenFound&lt;/code&gt; counter climbs. If you're deploying MPTCP behind a load balancer, you have to guarantee that every subflow of a session lands on the same backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to expect and what not to
&lt;/h2&gt;

&lt;p&gt;On the bandwidth side, expectations need to be set correctly. The kernel documentation lists aggregation as an explicit use case: using multiple paths at the same time for higher throughput, for instance combining a fixed and a mobile network to send files faster. So saying "it doesn't add up" would be wrong. But addition isn't a guarantee either: the gain depends on the latency and loss profile of the paths, on the receive buffer, and on head-of-line blocking. Combine two wildly dissimilar links and the total looks less and less like the arithmetic sum — which is also why the same list keeps "best network selection" as a separate item.&lt;/p&gt;

&lt;p&gt;The resilience side, by contrast, is far more concrete. A subflow created with the &lt;code&gt;backup&lt;/code&gt; flag waits quietly as long as the primary path works, and carries the session without a break when the primary goes down. How fast that switch happens is governed by &lt;code&gt;net.mptcp.stale_loss_cnt&lt;/code&gt;: the number of retransmission intervals before a subflow is declared stale, defaulting to 4, with lower values making active-backup switching faster according to the documentation.&lt;/p&gt;

&lt;p&gt;A little-known detail on this side is &lt;code&gt;net.mptcp.close_timeout&lt;/code&gt; (60 seconds by default), the "make-after-break" timeout in the documentation's own words. As long as the application makes no &lt;code&gt;close&lt;/code&gt; or &lt;code&gt;shutdown&lt;/code&gt; syscall, the socket keeps its state for that long after the last subflow is removed, and only then moves to &lt;code&gt;TCP_CLOSE&lt;/code&gt;. So a connection can sit path-less for a while. For it to carry on, the peer has to hold state too and a successful MP_JOIN has to arrive from the new address; if you can establish the second path before the break, you've already done better.&lt;/p&gt;

&lt;p&gt;So if you buy MPTCP as a "bandwidth combiner" you're likely to be disappointed; look at it as "redundancy that doesn't drop the session" and it's the right tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't talk about it without measuring
&lt;/h2&gt;

&lt;p&gt;The sneakiest thing about MPTCP is that everything looks normal when it isn't working. So learn the verification commands before you touch the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# list MPTCP sockets&lt;/span&gt;
ss &lt;span class="nt"&gt;-M&lt;/span&gt;

&lt;span class="c"&gt;# watch subflow creation live&lt;/span&gt;
ip mptcp monitor

&lt;span class="c"&gt;# client side: did I try, did the peer answer, did I fall back?&lt;/span&gt;
nstat &lt;span class="nt"&gt;-az&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"MPCapableSYNTX|MPCapableSYNACKRX|MPCapableFallbackSYNACK|MPJoinSynAckRx"&lt;/span&gt;

&lt;span class="c"&gt;# server side: did it reach me, did it complete, did it fall back, did a join arrive?&lt;/span&gt;
nstat &lt;span class="nt"&gt;-az&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"MPCapableSYNRX|MPCapableACKRX|MPCapableFallbackACK|MPJoinSynRx|MPJoinNoTokenFound"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the counters are directional; getting this wrong is the most common way to misread them. The naming in the kernel source is explicit: &lt;code&gt;MPCapableFallbackACK&lt;/code&gt; counts the server-side fallback and &lt;code&gt;MPCapableFallbackSYNACK&lt;/code&gt; the client-side one. Likewise &lt;code&gt;MPJoinSynRx&lt;/code&gt; increments on the side that &lt;em&gt;receives&lt;/em&gt; MP_JOIN; on the client that creates the subflow, the counter to watch is &lt;code&gt;MPJoinSynAckRx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That yields three separate diagnoses. On a client, if &lt;code&gt;MPCapableSYNTX&lt;/code&gt; is zero the application never attempts MPTCP — the first door. If there are attempts but &lt;code&gt;MPCapableFallbackSYNACK&lt;/code&gt; is climbing, the peer or the path is refusing — the third door. If the connection is established as MPTCP yet &lt;code&gt;MPJoinSynAckRx&lt;/code&gt; stays at zero, no additional subflow was ever born — the second door, meaning your endpoints and limits. On a server the same three questions are asked with &lt;code&gt;MPCapableSYNRX&lt;/code&gt;, &lt;code&gt;MPCapableFallbackACK&lt;/code&gt; and &lt;code&gt;MPJoinSynRx&lt;/code&gt;; since my machine is a server, the meaningful zero there is &lt;code&gt;MPCapableSYNRX&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without that split, what you're doing isn't tuning, it's guessing.&lt;/p&gt;

&lt;p&gt;You're not condemned to blindness inside the application either. The kernel exposes four socket options at the &lt;code&gt;SOL_MPTCP&lt;/code&gt; level (value 284): &lt;code&gt;MPTCP_INFO&lt;/code&gt; for connection-level information, &lt;code&gt;MPTCP_TCPINFO&lt;/code&gt; for an array of per-subflow &lt;code&gt;tcp_info&lt;/code&gt;, &lt;code&gt;MPTCP_SUBFLOW_ADDRS&lt;/code&gt; for the subflow addresses, and &lt;code&gt;MPTCP_FULL_INFO&lt;/code&gt; for subflow info plus the &lt;code&gt;tcp_info&lt;/code&gt; array plus the connection-level information together. If you write your own service, exporting "how many paths am I running on" as a metric is a few lines of work — and it's the most honest way to see on a dashboard whether one of your links has quietly died.&lt;/p&gt;

&lt;p&gt;If your policy needs to differ per connection, that's the sign the in-kernel manager is too narrow; that's when you move to the userspace manager, &lt;code&gt;mptcpd&lt;/code&gt;. Unnecessary complexity for most setups, but the right place if you're going to write a rule like "sessions from this customer must not use the second link".&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this really your problem?
&lt;/h2&gt;

&lt;p&gt;MPTCP's biggest constraint is organisational: you need to control both ends. If you want multi-link resilience between your own client and your own server, it's the right tool. But if you want to split traffic heading for arbitrary internet services, you're left with plain TCP unless the far end speaks it.&lt;/p&gt;

&lt;p&gt;So the first question to ask is whether the layer of your problem really is the transport layer. Sharing traffic leaving a branch office across two links, or picking a path based on link quality, are solved in the routing layer, not the transport layer; the approach in &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/sd-wan-sla-probe-ile-yol-secimi-ve-incident-triage/" rel="noopener noreferrer"&gt;my article about path selection with SLA probes in SD-WAN&lt;/a&gt; is that domain's answer. MPTCP is about a single session surviving over two paths — a different problem entirely.&lt;/p&gt;

&lt;p&gt;If you've decided to try it, an ordered checklist helps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kernel and tools: &lt;code&gt;sysctl net.mptcp.enabled&lt;/code&gt; and the flag list in &lt;code&gt;ip mptcp help&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Application: &lt;code&gt;IPPROTO_MPTCP&lt;/code&gt; in the source, &lt;code&gt;SocketProtocol=mptcp&lt;/code&gt; on systemd 257+, or &lt;code&gt;mptcpize&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Policy: &lt;code&gt;signal&lt;/code&gt; endpoints on the server, &lt;code&gt;subflow&lt;/code&gt; on the client; lift &lt;code&gt;subflows&lt;/code&gt; and &lt;code&gt;add_addr_accepted&lt;/code&gt; off zero with &lt;code&gt;ip mptcp limits set&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Routing: a &lt;code&gt;dev&lt;/code&gt; binding on every endpoint, and source-based routes where needed.&lt;/li&gt;
&lt;li&gt;Verification: the output of &lt;code&gt;ss -M&lt;/code&gt; and the &lt;code&gt;MPCapable*&lt;/code&gt; counters — see both the attempt and the fallback.&lt;/li&gt;
&lt;li&gt;Firewalls: on 6.12+ kernels, if blackhole detection has kicked in, wait out &lt;code&gt;blackhole_timeout&lt;/code&gt; or repeat the test over a clean path.&lt;/li&gt;
&lt;li&gt;Persistence: &lt;code&gt;ip mptcp endpoint&lt;/code&gt; and &lt;code&gt;limits&lt;/code&gt; are runtime settings and vanish on reboot. Don't call it done until they're wired into networkd/netplan configuration or a unit that runs at boot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Being enabled is not being used
&lt;/h2&gt;

&lt;p&gt;The real lesson MPTCP left me with isn't about the protocol. In modern systems, a feature being "supported" no longer means it is working; the support chain runs from the application to the kernel, on to the far end and through every firewall in between, and any link in that chain can quietly revert to the old behaviour.&lt;/p&gt;

&lt;p&gt;That silence shouldn't be underestimated either. The system doesn't raise an error; it simply picks the less capable path and tells you nothing. I read that not as a flaw but as a transfer of responsibility: showing that a feature actually works is now the job of whoever measures. The 63 zeroed counters on my server are the proof — the kernel has been doing its part for years; the missing party is me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/networking/mptcp.html" rel="noopener noreferrer"&gt;Linux kernel: MPTCP documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/networking/mptcp-sysctl.html" rel="noopener noreferrer"&gt;Linux kernel: net.mptcp sysctl reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/network/iproute2/iproute2.git/plain/man/man8/ip-mptcp.8" rel="noopener noreferrer"&gt;iproute2: ip-mptcp(8) man page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc8684.html" rel="noopener noreferrer"&gt;RFC 8684 — TCP Extensions for Multipath Operation with Multiple Addresses&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/multipath-tcp/mptcpd/blob/main/man/mptcpize.8.in" rel="noopener noreferrer"&gt;mptcpd: mptcpize(8) man source&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/systemd/blob/main/NEWS" rel="noopener noreferrer"&gt;systemd NEWS — MPTCP support in .socket units&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>networking</category>
      <category>tcp</category>
      <category>kernel</category>
    </item>
    <item>
      <title>Keep the Kernel Running: systemd soft-reboot</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 05 Sep 2026 16:45:14 +0000</pubDate>
      <link>https://dev.to/merbayerp/keep-the-kernel-running-systemd-soft-reboot-8dm</link>
      <guid>https://dev.to/merbayerp/keep-the-kernel-running-systemd-soft-reboot-8dm</guid>
      <description>&lt;p&gt;Rebooting is the bluntest instrument we own. A service gets stuck, a library is updated, we say "something happened but I couldn't find out what" — and we always press the same button. That button charges the same fee regardless of how big the problem was: every process dies, the kernel stops, the hardware resets, the firmware performs its own little ceremony, the boot loader shows up, the kernel comes back. On a desktop this is called "time for a coffee"; in a server room where you stare at the screen for minutes while a disk controller introduces itself, it is called an outage.&lt;/p&gt;

&lt;p&gt;The annoying part is this: in most of the cases where we pay that price, we have no quarrel with the hardware or the kernel at all. Our quarrel is with userspace — with services, libraries, half-finished state. The &lt;code&gt;soft-reboot&lt;/code&gt; that systemd introduced in version 254 draws exactly that line: it rebuilds userspace from scratch and never touches the kernel.&lt;/p&gt;

&lt;p&gt;My argument in this article is that describing &lt;code&gt;soft-reboot&lt;/code&gt; as a "fast reboot" is both wrong and dangerous. It is a different operation with different guarantees. Anyone who uses it without knowing what it gives you — and, more importantly, what it does not — will one day discover at the worst possible moment that their most trusted tool only did half the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we call a "reboot" is really seven separate jobs
&lt;/h2&gt;

&lt;p&gt;While the upstream documentation lists what &lt;code&gt;soft-reboot&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; do, it also spells out how many stages a normal reboot goes through. A userspace reboot passes through none of the following: the second phase of a regular shutdown (&lt;code&gt;systemd-shutdown&lt;/code&gt;), the third phase (the return to the initrd context), the hardware reboot operation, firmware initialization, boot loader initialization, kernel initialization, and initrd initialization.&lt;/p&gt;

&lt;p&gt;Looking at that list, it becomes clear how carelessly we have been using the word "reboot" for years. We call seven jobs by a single name and pay for all of them together.&lt;/p&gt;

&lt;p&gt;So what does &lt;code&gt;systemctl soft-reboot&lt;/code&gt; actually do? It sends &lt;code&gt;SIGTERM&lt;/code&gt; to any processes left running — without waiting for them to exit — and follows up with &lt;code&gt;SIGKILL&lt;/code&gt;. If the &lt;code&gt;/run/nextroot/&lt;/code&gt; directory exists (it may be a regular directory, a directory mount point, or a symlink to either), it switches the file system root to it. Then it re-executes the service manager off the — now possibly new — root, which enqueues a new boot transaction just like a normal boot.&lt;/p&gt;

&lt;p&gt;PID 1 survives, but it renews itself. The kernel has no idea any of this happened.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtzeXN0ZW1jdGwgc29mdC1yZWJvb3RdIC0tPiBCW1NJR1RFUk0gdG8gcmVtYWluaW5nIHByb2Nlc3Nlczxici8-dGhlbiBTSUdLSUxMXQogIEIgLS0-IEN7RG9lcyAvcnVuL25leHRyb290IGV4aXN0P30KICBDIC0tPnxZZXN8IERbUm9vdCBmaWxlIHN5c3RlbSBzd2l0Y2hlZDxici8-dG8gdGhlIG5ldyB0cmVlXQogIEMgLS0-fE5vfCBFW1NhbWUgcm9vdCBpcyBrZXB0XQogIEQgLS0-IEZbU2VydmljZSBtYW5hZ2VyPGJyLz5pcyByZS1leGVjdXRlZF0KICBFIC0tPiBGCiAgRiAtLT4gR1tOZXcgYm9vdCB0cmFuc2FjdGlvbiBpcyBlbnF1ZXVlZDxici8-dW5pdHMgc3RhcnQgZnJvbSBzY3JhdGNoXQogIEEgLS5za2lwcGVkLi0-IEhbc3lzdGVtZC1zaHV0ZG93biBwaGFzZTxici8-cmV0dXJuIHRvIGluaXRyZDxici8-aGFyZHdhcmUgcmVzZXQ8YnIvPmZpcm13YXJlPGJyLz5ib290IGxvYWRlcjxici8-a2VybmVsICsgaW5pdHJkXQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtzeXN0ZW1jdGwgc29mdC1yZWJvb3RdIC0tPiBCW1NJR1RFUk0gdG8gcmVtYWluaW5nIHByb2Nlc3Nlczxici8-dGhlbiBTSUdLSUxMXQogIEIgLS0-IEN7RG9lcyAvcnVuL25leHRyb290IGV4aXN0P30KICBDIC0tPnxZZXN8IERbUm9vdCBmaWxlIHN5c3RlbSBzd2l0Y2hlZDxici8-dG8gdGhlIG5ldyB0cmVlXQogIEMgLS0-fE5vfCBFW1NhbWUgcm9vdCBpcyBrZXB0XQogIEQgLS0-IEZbU2VydmljZSBtYW5hZ2VyPGJyLz5pcyByZS1leGVjdXRlZF0KICBFIC0tPiBGCiAgRiAtLT4gR1tOZXcgYm9vdCB0cmFuc2FjdGlvbiBpcyBlbnF1ZXVlZDxici8-dW5pdHMgc3RhcnQgZnJvbSBzY3JhdGNoXQogIEEgLS5za2lwcGVkLi0-IEhbc3lzdGVtZC1zaHV0ZG93biBwaGFzZTxici8-cmV0dXJuIHRvIGluaXRyZDxici8-aGFyZHdhcmUgcmVzZXQ8YnIvPmZpcm13YXJlPGJyLz5ib290IGxvYWRlcjxici8-a2VybmVsICsgaW5pdHJkXQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="689" height="1032"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The survivors: /run, sockets and file descriptors
&lt;/h2&gt;

&lt;p&gt;What makes &lt;code&gt;soft-reboot&lt;/code&gt; interesting is not its speed but what comes out of the transition alive. The documentation lists these one by one under "resource pass-through".&lt;/p&gt;

&lt;p&gt;The most valuable of them is the file descriptor store. Descriptors belonging to services that stay active until the very end are passed to the next boot and placed back into the same unit's store. For this to work, the unit must declare &lt;code&gt;DefaultDependencies=no&lt;/code&gt; (and must not tie itself to shutdown with something like &lt;code&gt;Conflicts=shutdown.target&lt;/code&gt;); alternatively, &lt;code&gt;FileDescriptorStorePreserve=&lt;/code&gt; keeps the store pinned even while the unit is down.&lt;/p&gt;

&lt;p&gt;The distinction looks thin on paper, but the consequence isn't; journald is the best illustration. On my own server running Ubuntu 24.04, journald's settings look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl show systemd-journald.service &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -p FileDescriptorStoreMax -p FileDescriptorStorePreserve -p DefaultDependencies
FileDescriptorStoreMax=4224
FileDescriptorStorePreserve=restart
DefaultDependencies=no

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemd-analyze fdstore systemd-journald.service
&lt;span class="go"&gt;FDNAME TYPE DEVNO     INODE RDEVNO PATH               FLAGS
stored sock 0:8   405178459 -      socket:[405178459] rw
stored sock 0:8   375205204 -      socket:[375205204] rw
stored sock 0:8   371286301 -      socket:[371286301] rw
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What sits in that store are the sockets applications write their logs to. When journald is restarted it takes those sockets back, so services don't stumble with "I can't write logs". But &lt;code&gt;FileDescriptorStorePreserve=restart&lt;/code&gt; is the default value, and it makes a narrow promise: the store is only kept across a restart of the service, and released when the service is stopped altogether.&lt;/p&gt;

&lt;p&gt;And journald is stopped precisely during a &lt;code&gt;soft-reboot&lt;/code&gt; transition. The upstream unit file does this deliberately, with the reasoning right there in a comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# To avoid journald SIGKILLed during soft-reboot and corrupting journals.
&lt;/span&gt;&lt;span class="py"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;soft-reboot.target&lt;/span&gt;
&lt;span class="py"&gt;Conflicts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;soft-reboot.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So on the 255 running on my server, journald releases its store on the way into the transition. Upstream treated this as a bug and fixed it in 256 by adding &lt;code&gt;FileDescriptorStorePreserve=yes&lt;/code&gt; to the journald unit; the commit message describes the symptom precisely: a unit using the default &lt;code&gt;StandardOutput=journal&lt;/code&gt; lost its stdout/stderr sockets when journald stopped, because the descriptors on journald's side were not preserved.&lt;/p&gt;

&lt;p&gt;The reason I care about this detail is that &lt;code&gt;soft-reboot&lt;/code&gt; is not magic that works on its own. Which resources come out of the transition alive depends on lines written into individual units — and upstream itself left its own logging service out in the first release. Don't enter a transition without asking the same two questions about your own critical service: does this unit stop during the transition, and is its store preserved?&lt;/p&gt;

&lt;p&gt;The list of survivors doesn't end there. Descriptors associated with &lt;code&gt;.socket&lt;/code&gt; units remain open and connectible if the units aren't stopped during the transition — so an incoming request isn't refused, it waits in the queue. The &lt;code&gt;/run/&lt;/code&gt; file system stays mounted and populated; it is the officially recommended place to pass state between two userspace reboot cycles. File system mounts can also stay mounted if they are configured to remain until the very end of shutdown.&lt;/p&gt;

&lt;p&gt;Even processes themselves can survive, but at a cost. The recipe in the documentation goes like this: the unit uses &lt;code&gt;SurviveFinalKillSignal=yes&lt;/code&gt; to be skipped in that final &lt;code&gt;SIGTERM&lt;/code&gt;/&lt;code&gt;SIGKILL&lt;/code&gt; spree, &lt;code&gt;IgnoreOnIsolate=yes&lt;/code&gt; so it isn't stopped on isolate, &lt;code&gt;DefaultDependencies=no&lt;/code&gt; to step out of the normal shutdown chain, &lt;code&gt;After=basic.target&lt;/code&gt; for correct ordering at boot, plus &lt;code&gt;Conflicts=&lt;/code&gt; and &lt;code&gt;Before=&lt;/code&gt; lines so it does stop on real shutdowns (reboot, poweroff, rescue, emergency).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;My Surviving Service&lt;/span&gt;
&lt;span class="py"&gt;SurviveFinalKillSignal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;IgnoreOnIsolate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="py"&gt;DefaultDependencies&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;basic.target&lt;/span&gt;
&lt;span class="py"&gt;Conflicts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;reboot.target kexec.target poweroff.target halt.target rescue.target emergency.target&lt;/span&gt;
&lt;span class="py"&gt;Before&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;shutdown.target rescue.target emergency.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Templated units add a slice detail: since a &lt;code&gt;foo@test.service&lt;/code&gt; instance runs in a slice named &lt;code&gt;system-foo.slice&lt;/code&gt; by default, the same three lines must be added to that slice too. And applications that publish a service over D-Bus need to re-establish the connection, because the broker is stopped and started again.&lt;/p&gt;

&lt;p&gt;What strikes me here is that upstream refuses to market its own feature. The documentation suggests using this pass-through sparingly, and specifically recommends avoiding letting processes survive. The reasoning is solid: as long as a process lives, code updates are necessarily incomplete, and since a process pins the file system beneath it, two versions of the OS may be held in memory at once. In other words, the enthusiasm of "let me keep everything alive" shortens the outage while making the system more fragile.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't solve: the kernel, sysctl, hardware
&lt;/h2&gt;

&lt;p&gt;In the documentation's own words there are two big gaps. First: because the kernel is not reset and keeps running, the OS update remains incomplete. Second: kernel settings under &lt;code&gt;/proc/sys/&lt;/code&gt; (i.e. sysctl) and &lt;code&gt;/sys/&lt;/code&gt; are not reset.&lt;/p&gt;

&lt;p&gt;Don't take the second one lightly. Anyone who runs &lt;code&gt;sysctl -w&lt;/code&gt; by hand one evening and thinks "I'll reboot in the morning, it'll clear itself" will be surprised to find the same setting still in place after a &lt;code&gt;soft-reboot&lt;/code&gt;. The fix is what the docs also point at: keep sufficiently comprehensive &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; files — derive the system's state from files rather than from memory.&lt;/p&gt;

&lt;p&gt;The first gap points straight at security. &lt;code&gt;soft-reboot&lt;/code&gt; is not an answer to a kernel CVE. There you need either a real reboot or live patching; we compared those two in detail in &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/kurumsal-linuxta-kernel-live-patching-ve-bakim-modeli/" rel="noopener noreferrer"&gt;kernel live patching and the maintenance model in enterprise Linux&lt;/a&gt;. The documentation names the same two mitigations: kernel live-patching and proper sysctl files.&lt;/p&gt;

&lt;p&gt;One more small detail that can bite: because &lt;code&gt;systemd-shutdown&lt;/code&gt; is not executed, the executables under &lt;code&gt;/usr/lib/systemd/system-shutdown/&lt;/code&gt; are not executed either. If your team leaves shutdown cleanup there, &lt;code&gt;soft-reboot&lt;/code&gt; quietly skips it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list left behind after &lt;code&gt;apt upgrade&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The most everyday benefit of this feature isn't in exotic image-based systems but in that familiar scene with an ordinary package manager: the upgrade finishes, the libraries on disk are new, but running processes still carry the old code in memory. On Debian and Ubuntu, &lt;code&gt;needrestart&lt;/code&gt; is what reminds you; version 3.6 is installed on my own server and its description says the job out loud — check which daemons need to be restarted after library upgrades.&lt;/p&gt;

&lt;p&gt;Looking at that list and typing &lt;code&gt;systemctl restart&lt;/code&gt; one by one usually works. But the moment you miss an ordering dependency, or a process that isn't on the list is still holding the old library, you're left with a half-finished upgrade — and that half-finished state usually shows up weeks later as an unrelated-looking bug. &lt;code&gt;soft-reboot&lt;/code&gt; is a broad broom here: it starts everything again with the new code, without paying for the firmware round.&lt;/p&gt;

&lt;p&gt;With one condition: this broom never visits the kernel. On Ubuntu, if the &lt;code&gt;/var/run/reboot-required&lt;/code&gt; file is there, that file is asking for a real reboot, not a &lt;code&gt;soft-reboot&lt;/code&gt;. Don't conflate them; the first is a problem of services, the second a problem of the kernel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Containers: the kill spree doesn't discriminate
&lt;/h2&gt;

&lt;p&gt;On a host running Docker, the first question that came to my mind was whether &lt;code&gt;--live-restore&lt;/code&gt; would save me.&lt;/p&gt;

&lt;p&gt;It won't, because we're talking about two different things. Docker's live restore keeps containers running if the daemon crashes or is restarted deliberately. Its documented limits are clear too: it is only supported across patch releases, reconnection can break if the daemon configuration changes, Swarm services are out of scope, and if the daemon stays down long enough the default 64K logging buffer can fill up and block containers from logging.&lt;/p&gt;

&lt;p&gt;But with &lt;code&gt;soft-reboot&lt;/code&gt;, the daemon restarting isn't even the issue. &lt;strong&gt;Every&lt;/strong&gt; remaining process gets &lt;code&gt;SIGTERM&lt;/code&gt; and then &lt;code&gt;SIGKILL&lt;/code&gt;; the only exceptions are units that have explicitly declared themselves exempt with the recipe above. Container processes are inside that spree. So on a container host, &lt;code&gt;soft-reboot&lt;/code&gt; means containers restart.&lt;/p&gt;

&lt;p&gt;Worse, live restore doesn't improve things here — it makes them harsher. With it off, containers go through the daemon's own stop flow when &lt;code&gt;docker.service&lt;/code&gt; is stopped, so the application sees the grace period &lt;code&gt;docker stop&lt;/code&gt; grants. With it on, containers stay alive after the daemon goes down, so the spree at the end of the transition catches them as leftover processes and they take a &lt;code&gt;SIGKILL&lt;/code&gt; without a chance at an orderly shutdown. For a container writing data, that difference matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet trap: /run/nextroot
&lt;/h2&gt;

&lt;p&gt;There is a behavior in the release notes that doesn't draw much attention but will surprise you in operations: since systemd 255, when a reboot operation is invoked, &lt;code&gt;systemctl&lt;/code&gt; automatically performs a &lt;code&gt;soft-reboot&lt;/code&gt; if a new root file system has been set up under &lt;code&gt;/run/nextroot/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The phrase "set up" stays vague in the man page; the actual condition lives in the source, and it has two gates. The logind side requires &lt;code&gt;/run/nextroot&lt;/code&gt; to be a valid OS tree — in practice, an &lt;code&gt;os-release&lt;/code&gt; must be found inside it, so an empty directory doesn't trigger the switch. From systemd 256 onwards a second gate was added on the &lt;code&gt;systemctl&lt;/code&gt; side: the directory must also be a mount point. The comment in the code spells out why — if the new root were stored directly on the &lt;code&gt;/run&lt;/code&gt; tmpfs, &lt;code&gt;/run/nextroot&lt;/code&gt; could never go away, and every &lt;code&gt;reboot&lt;/code&gt; would soft-reboot forever.&lt;/p&gt;

&lt;p&gt;The operational translation: in a team running an image-based update flow, what actually happens when someone types &lt;code&gt;reboot&lt;/code&gt; depends on the machine's state at that moment. An operator who believes they applied a kernel patch may carry on with a machine whose kernel is unchanged. The ordering makes that risk worse: in logind's decision the soft-reboot branch comes before the kexec branch. Even if you prepared a new kernel with &lt;code&gt;kexec --load&lt;/code&gt;, a &lt;code&gt;reboot&lt;/code&gt; with nextroot set up gives you a soft reboot, and that kernel is never loaded. This detail matters most on immutable or image-based setups; in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/systemd-sysext-sunucuya-iz-birakmadan-arac-eklemek/" rel="noopener noreferrer"&gt;my article about adding tools to a server without leaving a trace&lt;/a&gt; I also touched on A/B image flows with &lt;code&gt;systemd-sysupdate&lt;/code&gt; — in that world, preparing a new root tree and switching to it is nothing unusual.&lt;/p&gt;

&lt;p&gt;There is an escape hatch: if the &lt;code&gt;SYSTEMCTL_SKIP_AUTO_SOFT_REBOOT=1&lt;/code&gt; environment variable is set, &lt;code&gt;systemctl&lt;/code&gt; skips the automatic switch and performs the real reboot you asked for. The same logic exists for kexec, and &lt;code&gt;SYSTEMCTL_SKIP_AUTO_KEXEC=1&lt;/code&gt; turns that one off. A small but painful detail: you have to pass the variable through &lt;code&gt;sudo&lt;/code&gt;, because the sudoers default resets the environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;SYSTEMCTL_SKIP_AUTO_SOFT_REBOOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 systemctl reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of this translation also goes through the logind path. &lt;code&gt;systemctl reboot --force&lt;/code&gt; bypasses logind, so neither the automatic soft reboot nor the kexec substitution kicks in — but &lt;code&gt;--force&lt;/code&gt; also skips the orderly shutdown of units, so treat it as an emergency exit rather than a solution.&lt;/p&gt;

&lt;p&gt;And run the right check; &lt;code&gt;ls -ld /run/nextroot&lt;/code&gt; produces false positives, because an existing empty directory doesn't trigger the switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;findmnt /run/nextroot
&lt;span class="nb"&gt;ls&lt;/span&gt; /run/nextroot/usr/lib/os-release /run/nextroot/etc/os-release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make comparing &lt;code&gt;uname -r&lt;/code&gt; after maintenance a habit too. If you're writing automation, state your intent explicitly: &lt;code&gt;systemctl soft-reboot&lt;/code&gt; when you want userspace, and &lt;code&gt;systemctl reboot&lt;/code&gt; with the escape variable when you want the kernel renewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  systemd already took care of measuring it
&lt;/h2&gt;

&lt;p&gt;I'm not going to invent a number for "how much faster?" — and there's no need, because systemd records it itself. As of version 256 the moment shutdown began is passed to the next service manager, which logs the overall "grey-out" time of the operation: from the start of shutdown until the system is fully up again. The same release added a counter of how many soft reboot cycles the system has gone through, exposed over D-Bus. In 257 that counter is also handed to generator processes as the &lt;code&gt;$SYSTEMD_SOFT_REBOOTS_COUNT&lt;/code&gt; environment variable.&lt;/p&gt;

&lt;p&gt;I verified on my own machine that the counter depends on the version; on a server carrying systemd 255.4 the query comes back empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl &lt;span class="nt"&gt;--version&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="go"&gt;systemd 255 (255.4-1ubuntu8.17)

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; SoftRebootsCount
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if you plan to build telemetry around "how many soft reboots have I done", check the systemd versions across your fleet first. As I write this the stable line is at 261.2 and the first release candidate for 262 was tagged on 1 September 2026 — but the version running on your servers is most likely whatever your distribution ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to decide
&lt;/h2&gt;

&lt;p&gt;The question to ask about your own setup isn't "should I use soft-reboot?" but "which layer is this maintenance actually about?" The answer picks the tool.&lt;/p&gt;

&lt;p&gt;But the first question across a fleet is simpler: do I even have this command? &lt;code&gt;soft-reboot&lt;/code&gt; arrived with systemd 254; before that it doesn't exist at all. Debian 12 bookworm carries 252, so the command isn't there; Debian 13 trixie carries 257, and Ubuntu 24.04 carries 255 — that's the version on my own server. Collect &lt;code&gt;systemctl --version&lt;/code&gt; across every machine before you write the plan; on a mixed fleet that single line decides whether the runbook is applicable at all.&lt;/p&gt;

&lt;p&gt;Work that stays in userspace — library and service updates, applying a configuration change everywhere, the distribution's "restart these 14 services" list, opening a wedged userspace with a clean slate, switching to a new root tree on an image-based system — is &lt;code&gt;soft-reboot&lt;/code&gt;'s natural territory. Whenever you suspect the kernel, a driver or module, sysctl, or the hardware, a real reboot is the only honest answer.&lt;/p&gt;

&lt;p&gt;Don't forget the step in between either: &lt;code&gt;systemctl kexec&lt;/code&gt;. This command shuts down and reboots the system via kexec; it loads a kexec kernel if one isn't loaded yet, or fails if it can't (&lt;code&gt;--force&lt;/code&gt; falls back to a normal reboot). That automatic loading has an easily missed prerequisite: because the enumeration follows the Boot Loader Specification, the system must be using UEFI and the boot loader entries must be configured appropriately — &lt;code&gt;bootctl list&lt;/code&gt; shows you what it sees. On a classic BIOS/GRUB install, this command won't load a kernel for you unless you prepared one by hand with &lt;code&gt;kexec --load&lt;/code&gt;. Loading it by hand is also what you want when you need a custom initrd or extra kernel command line options. So you have three steps available: &lt;code&gt;soft-reboot&lt;/code&gt; never touches the kernel, &lt;code&gt;kexec&lt;/code&gt; renews the kernel but skips the firmware and boot loader round, and a full reboot resets everything. If you suspect the hardware, nothing but the third will do the job. If only the kernel needs patching and the machine must never go down, live patching is the fourth option on the table.&lt;/p&gt;

&lt;p&gt;Run the first attempt on a test machine born from the same image rather than in production, and watch what the log stream, socket connections and database clients do on the way back. Two preparations are mandatory: out-of-band access (your SSH session dies during the transition, and if a unit gets stuck on the way back you're left without a console) and making sure the services that must come up at boot are actually &lt;code&gt;enabled&lt;/code&gt; — the system doesn't remember what you started by hand, it builds a normal boot transaction.&lt;/p&gt;

&lt;p&gt;A short checklist for the first run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is out-of-band console access (IPMI/BMC/provider console) open and verified?&lt;/li&gt;
&lt;li&gt;Have the services that must come up been confirmed with &lt;code&gt;systemctl is-enabled&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Does &lt;code&gt;/run/nextroot&lt;/code&gt; exist — i.e. what would actually happen if you typed &lt;code&gt;reboot&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Is there a pending kernel-patch marker (&lt;code&gt;/var/run/reboot-required&lt;/code&gt;) sitting there?&lt;/li&gt;
&lt;li&gt;Have you written down the three post-transition checks (&lt;code&gt;systemctl --failed&lt;/code&gt;, health endpoints, version verification)?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more note: the man page says &lt;code&gt;systemd-soft-reboot.service&lt;/code&gt; and related units should never be executed directly. The correct entry point is &lt;code&gt;systemctl soft-reboot&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Get to know the failure modes in advance too; they all follow from the nature of the transition. A service that depends on an old file system pinned by another surviving process may meet an unexpected version at startup. A client speaking over D-Bus may have lost its connection — because the broker restarted — and may not reconnect on its own. Lock files you left in &lt;code&gt;/run/&lt;/code&gt; stay where they are even though their owner is dead, because &lt;code&gt;/run/&lt;/code&gt; isn't cleared during the transition: a gift if you want to pass state, a trap if you delegate lock cleanup to boot.&lt;/p&gt;

&lt;p&gt;As for a rollback plan: there isn't one. If userspace doesn't come back after a &lt;code&gt;soft-reboot&lt;/code&gt;, there is no button that says "return to the previous state"; your only path is a real reboot from the console. On a mutable root, that reboot brings you back to the upgraded packages, not to the old version. So your actual rollback mechanism isn't &lt;code&gt;soft-reboot&lt;/code&gt; at all — it's whatever update method sits beneath it: A/B images, snapshots, or package pinning.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no single thing called a restart
&lt;/h2&gt;

&lt;p&gt;What &lt;code&gt;soft-reboot&lt;/code&gt; really taught me isn't a command but a distinction. For years we treated "restart" as one operation and bought the most expensive version of it every time. Yet a system's life isn't measured by a single clock: the kernel has its clock, userspace has another, and individual services run on faster ones still. Knowing which clock you need to reset in order to fix a problem is half the work.&lt;/p&gt;

&lt;p&gt;The real gain here, in my view, isn't shaving a few minutes off an outage. The real gain is replacing the "turn it all off and on again" reflex with the question "which layer broke?" Once you start asking that, the next step arrives on its own — and sometimes the answer is to not touch the machine at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/systemd/blob/main/man/systemd-soft-reboot.service.xml" rel="noopener noreferrer"&gt;systemd-soft-reboot.service(8) — upstream source of the man page&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/systemd/blob/main/man/systemd.service.xml" rel="noopener noreferrer"&gt;systemd.service(5) — file descriptor store settings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/systemd/systemd/blob/main/NEWS" rel="noopener noreferrer"&gt;systemd NEWS — changelog entries for v254, v255, v256 and v257&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kernel.org/livepatch/livepatch.html" rel="noopener noreferrer"&gt;Linux kernel: livepatch documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://ubuntu.com/security/livepatch" rel="noopener noreferrer"&gt;Ubuntu Livepatch — applying kernel patches without rebooting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/engine/daemon/live-restore/" rel="noopener noreferrer"&gt;Docker: live restore and its limits&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>systemd</category>
      <category>operations</category>
      <category>reliability</category>
    </item>
  </channel>
</rss>
