<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Lxc on Lessons learned</title><link>https://eghuro.cz/tags/lxc/</link><description>Recent content in Lxc on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Wed, 21 Oct 2026 13:13:00 +0200</lastBuildDate><atom:link href="https://eghuro.cz/tags/lxc/index.xml" rel="self" type="application/rss+xml"/><item><title>Unprivileged LXC containers share the host's per-user kernel limits: 'Too many open files' and 'disk quota exceeded'</title><link>https://eghuro.cz/posts/lxc-containers-share-host-kernel-limits/</link><pubDate>Wed, 21 Oct 2026 13:13:00 +0200</pubDate><guid>https://eghuro.cz/posts/lxc-containers-share-host-kernel-limits/</guid><description>&lt;p&gt;One day, two containers on the same host failed with errors that seemed unrelated. systemd in one of them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[4573]: Failed to create control group inotify object: Too many open files&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[4573]: Failed to allocate manager object: Too many open files&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[1]: Failed to start user@1000.service - User Manager for UID 1000.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Docker in another, where every container sat at &lt;code&gt;Exited (128)&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OCI runtime create failed: runc create failed: unable to start container process: error during container init: unable to join session keyring: unable to create session key: disk quota exceeded: unknown&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No file descriptors had run out, and no disk was full. Both errors come from per-user kernel quotas counted on the host, and every unprivileged container there was drawing from the same two.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13, kernel 6.12, LXC 6.0.4&lt;/li&gt;&#10;&lt;li&gt;More unprivileged containers than the hardware comfortably runs, all with the same idmap: container root is host UID 1000000&lt;/li&gt;&#10;&lt;li&gt;systemd in every container, Docker nested in some&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="too-many-open-files-inotify-instances"&gt;Too many open files: inotify instances &lt;/h2&gt;&#10;&lt;p&gt;systemd opens inotify instances to watch cgroups and units, and &lt;code&gt;inotify_init1()&lt;/code&gt; fails with &lt;code&gt;EMFILE&lt;/code&gt; when the user&amp;rsquo;s instance limit is reached. strerror turns that into &amp;ldquo;Too many open files&amp;rdquo;. The limit is &lt;code&gt;fs.inotify.max_user_instances&lt;/code&gt;, 128 by default.&lt;/p&gt;&#10;&lt;p&gt;Count the instances per user on the host:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo find /proc/&lt;span class="o"&gt;[&lt;/span&gt;0-9&lt;span class="o"&gt;]&lt;/span&gt;*/fd -lname &lt;span class="s1"&gt;&amp;#39;anon_inode:inotify&amp;#39;&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; cut -d/ -f3 &lt;span class="p"&gt;|&lt;/span&gt; xargs -I&lt;span class="o"&gt;{}&lt;/span&gt; ps -o &lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; -p &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &lt;span class="p"&gt;|&lt;/span&gt; uniq -c &lt;span class="p"&gt;|&lt;/span&gt; sort -rn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /proc/sys/fs/inotify/max_user_instances&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Host root held 23 instances, and uids in the container range (1000000 and up) held 104 between them: 127 of 128. On the first sighting, my coding agent got that far, then dropped it as a side effect of having started too many containers at once. That was half right, since each container&amp;rsquo;s systemd adds about five instances. A day later it came back.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;lxc&lt;/code&gt; package already ships a higher limit, and it wasn&amp;rsquo;t in effect:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /etc/sysctl.d/30-lxc-inotify.conf | grep -v &amp;#39;^#&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fs.inotify.max_user_instances = 1024&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /proc/sys/fs/inotify/max_user_instances&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;128&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The file was dated three days after the last boot. &lt;code&gt;/etc/sysctl.d&lt;/code&gt; is applied by &lt;code&gt;systemd-sysctl&lt;/code&gt; at boot, and the package&amp;rsquo;s install hadn&amp;rsquo;t applied it, so the setting sat on disk doing nothing.&lt;/p&gt;&#10;&#10;&lt;h2 id="disk-quota-exceeded-kernel-keys"&gt;Disk quota exceeded: kernel keys &lt;/h2&gt;&#10;&lt;p&gt;runc gives each container it starts a new session keyring. &lt;code&gt;add_key()&lt;/code&gt; fails with &lt;code&gt;EDQUOT&lt;/code&gt; when the user&amp;rsquo;s key quota is full, and strerror calls that &amp;ldquo;Disk quota exceeded&amp;rdquo;. The quota is &lt;code&gt;kernel.keys.maxkeys&lt;/code&gt;, 200 keys for any uid other than root. The host shows usage per UID:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /proc/key-users&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0: 127 126/126 107/1000000 2283/25000000&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1000: 12 12/12 12/200 86/20000&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1000000: 198 198/198 198/200 4189/20000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;198/200&lt;/code&gt; are keys used against the quota. UID 1000000 is root in every container, so all the containers shared those 200 keys, and Docker&amp;rsquo;s restart attempts had nothing left.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Raise the limits and apply them right away, not at the next boot. Incus recommends 2000 keys and 2 MB in its &lt;a&#10; href="https://linuxcontainers.org/incus/docs/main/reference/server_settings"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;server settings&lt;/a&gt;&#10;; 1024 inotify instances matches the &lt;code&gt;lxc&lt;/code&gt; package.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo tee /etc/sysctl.d/60-lxc-limits.conf &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;fs.inotify.max_user_instances = 1024&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;kernel.keys.maxkeys = 2000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;kernel.keys.maxbytes = 2000000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;EOF&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo sysctl -p /etc/sysctl.d/60-lxc-limits.conf&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With Ansible, &lt;code&gt;ansible.posix.sysctl&lt;/code&gt; with &lt;code&gt;sysctl_set: true&lt;/code&gt; writes the file and sets the live value in one task.&lt;/p&gt;&#10;&lt;p&gt;Then run &lt;code&gt;systemctl reset-failed&lt;/code&gt; in the affected container, and &lt;code&gt;docker start&lt;/code&gt; for the dead Docker containers. Right after, uid 1000000 held 226 keys, already over the old limit.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-two-quotas-are-counted-differently"&gt;Why the two quotas are counted differently &lt;/h2&gt;&#10;&lt;p&gt;The two quotas look similar, but separate idmaps would fix only one.&lt;/p&gt;&#10;&lt;p&gt;Keys are counted per host UID, against global sysctls. Containers that share an idmap share one quota. A separate idmap per container (&lt;code&gt;security.idmap.isolated&lt;/code&gt; in LXD and Incus) would give each its own.&lt;/p&gt;&#10;&lt;p&gt;inotify instances are counted per user namespace and charged up the chain of parent namespaces, at each level against the uid that created the child namespace. A new namespace&amp;rsquo;s own limit is &lt;code&gt;INT_MAX&lt;/code&gt;, so the limit that bites is the host&amp;rsquo;s, charged to whoever started the containers. LXC started as root charges host root, which is why the 23 instances of host root&amp;rsquo;s own daemons counted toward the same 128. Separate idmaps don&amp;rsquo;t change that.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On a container host, &amp;ldquo;Too many open files&amp;rdquo; and &amp;ldquo;disk quota exceeded&amp;rdquo; can mean that every container together hit a per-user kernel quota on the host. Check the host&amp;rsquo;s counters before you debug the container that complained.&lt;/p&gt;&#10;&lt;p&gt;Run the &lt;code&gt;find&lt;/code&gt; count and &lt;code&gt;cat /proc/key-users&lt;/code&gt; on your own container host and compare them with the limits before a container restart finds out for you.&lt;/p&gt;&#10;</description></item><item><title>Rootless Podman backup without app.ini, and a restore that fails with 'Cannot change ownership to uid 100999'</title><link>https://eghuro.cz/posts/rootless-podman-backup-missing-app-ini/</link><pubDate>Thu, 15 Oct 2026 17:23:00 +0200</pubDate><guid>https://eghuro.cz/posts/rootless-podman-backup-missing-app-ini/</guid><description>&lt;p&gt;Restoring a Gitea backup to a new host failed on every directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar: data/gitea: Cannot change ownership to uid 100999, gid 100999: Invalid argument&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar: data/gitea/queues: Cannot change ownership to uid 100999, gid 100999: Invalid argument&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one is easy to fix. The worse problem made no noise at all, and I found it just before: the nightly backup didn&amp;rsquo;t contain &lt;code&gt;app.ini&lt;/code&gt;. If you back up a rootless Podman volume with a plain &lt;code&gt;tar&lt;/code&gt; as the host user, yours may be missing files too.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;p&gt;Gitea 1.27 and PostgreSQL ran in rootless Podman on a VPS, with Gitea&amp;rsquo;s data in a bind mount under &lt;code&gt;~/gitea/data&lt;/code&gt;. The nightly backup ran as the user who owns the containers:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;podman &lt;span class="nb"&gt;exec&lt;/span&gt; gitea_db_1 pg_dump -U gitea gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-db-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.sql.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar c -C ~/gitea/data gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-data-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.tar.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The target was Gitea in Docker, inside an unprivileged LXC container on Debian.&lt;/p&gt;&#10;&lt;p&gt;The archive looked fine. It unpacked, and it contained all the repositories.&lt;/p&gt;&#10;&#10;&lt;h2 id="compare-the-archive-with-the-live-tree"&gt;Compare the archive with the live tree &lt;/h2&gt;&#10;&lt;p&gt;Before restoring, I compared the archive&amp;rsquo;s file list with the live data. List the live side from inside the user namespace, so that nothing is hidden from you:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh vps.example.com &lt;span class="s1"&gt;&amp;#39;cd ~/gitea/data &amp;amp;&amp;amp; podman unshare find gitea -type f&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &amp;gt; live.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar tjf gitea-data.tar.bz2 &lt;span class="p"&gt;|&lt;/span&gt; grep -v &lt;span class="s1"&gt;&amp;#39;/$&amp;#39;&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &amp;gt; backup.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;comm -23 live.txt backup.txt &amp;gt; missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;wc -l live.txt backup.txt missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 12359 live.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 12342 backup.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 17 missing.txt&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Among the 17 were the files Gitea can least do without:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/conf/app.ini&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/jwt/private.pem&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/git/.ssh/authorized_keys&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/avatars/... (3 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/indexers/... (4 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gitea/gitea/sessions/... (2 files)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;app.ini&lt;/code&gt; holds Gitea&amp;rsquo;s &lt;code&gt;SECRET_KEY&lt;/code&gt;, its internal tokens, and the mail settings. &lt;code&gt;private.pem&lt;/code&gt; signs its OAuth2 tokens. A restore from this backup would have started with fresh secrets: anything encrypted with the old &lt;code&gt;SECRET_KEY&lt;/code&gt;, such as two-factor secrets, would no longer decrypt, and the mailer would be unconfigured.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-tar-skipped-them"&gt;Why tar skipped them &lt;/h2&gt;&#10;&lt;p&gt;Rootless Podman maps the container&amp;rsquo;s UIDs onto the subordinate range in &lt;code&gt;/etc/subuid&lt;/code&gt;. With the usual entry:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;deploy:100000:65536&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;container root is you, and container UID 1000 (Gitea&amp;rsquo;s &lt;code&gt;git&lt;/code&gt; user) is host UID 100000 + 999 = 100999. Files that Gitea creates with mode &lt;code&gt;0600&lt;/code&gt; belong to 100999, and you can&amp;rsquo;t read them from the host. The repository objects were &lt;code&gt;0644&lt;/code&gt; in &lt;code&gt;0755&lt;/code&gt; directories, so tar read those, and the archive looked complete.&lt;/p&gt;&#10;&lt;p&gt;tar does complain, on stderr, and exits with status 2. In &lt;code&gt;tar c ... | bzip2 &amp;gt; file&lt;/code&gt;, though, the pipeline&amp;rsquo;s exit status is bzip2&amp;rsquo;s, which is 0. Without &lt;code&gt;set -o pipefail&lt;/code&gt; and someone reading the job&amp;rsquo;s stderr, nothing tells you.&lt;/p&gt;&#10;&#10;&lt;h2 id="fix-the-backup"&gt;Fix the backup &lt;/h2&gt;&#10;&lt;p&gt;Run tar inside the user namespace, where you are root over every mapped UID:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;set&lt;/span&gt; -o pipefail&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;podman unshare tar c -C ~/gitea/data gitea &lt;span class="p"&gt;|&lt;/span&gt; bzip2 -z9 &amp;gt; ~/backup/gitea-data-&lt;span class="k"&gt;$(&lt;/span&gt;date +%y%m%d&lt;span class="k"&gt;)&lt;/span&gt;.tar.bz2&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The archive then records the IDs as the container sees them (1000), not the host&amp;rsquo;s 100999.&lt;/p&gt;&#10;&lt;p&gt;To rescue the files without touching the live host, fetch only the missing ones the same way, then merge them into the old archive with bsdtar&amp;rsquo;s &lt;code&gt;@archive&lt;/code&gt; syntax:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh vps.example.com &lt;span class="s1"&gt;&amp;#39;cd ~/gitea/data &amp;amp;&amp;amp; podman unshare tar -c -T -&amp;#39;&lt;/span&gt; &amp;lt; missing.txt &amp;gt; missing.tar&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;bsdtar -czf gitea-complete.tar.gz @gitea-data.tar.bz2 @missing.tar&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="fix-the-restore"&gt;Fix the restore &lt;/h2&gt;&#10;&lt;p&gt;GNU tar run as root restores the owners recorded in the archive (&lt;code&gt;--same-owner&lt;/code&gt; is its default for root). Inside an unprivileged LXC container, root is itself a mapped user, and the container&amp;rsquo;s UIDs usually cover only 0 to 65535. UID 100999 doesn&amp;rsquo;t exist there, so &lt;code&gt;chown&lt;/code&gt; fails with &lt;code&gt;EINVAL&lt;/code&gt;, which tar reports as &lt;code&gt;Invalid argument&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Don&amp;rsquo;t restore owners from an archive made somewhere else. Unpack with &lt;code&gt;--no-same-owner&lt;/code&gt; and set the owners the new Gitea expects:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;tar -C /var/tmp/gitea-restore --no-same-owner -xzf gitea-complete.tar.gz&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp -a /var/tmp/gitea-restore/gitea/. /srv/gitea/data/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chown -R 1000:1000 /srv/gitea/data/git /srv/gitea/data/gitea&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chown -R root:root /srv/gitea/data/ssh&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This also covers the merged archive, which carries 100999 for some files and 1000 for others.&lt;/p&gt;&#10;&lt;p&gt;After the restore, check against the live instance: the repository count, &lt;code&gt;git fsck&lt;/code&gt; on every repository, and the SSH host key fingerprint on the Git SSH port, so existing clones still trust the new host. All three matched.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;Under rootless Podman, a host-side &lt;code&gt;tar&lt;/code&gt; leaves out the files the container keeps private, which are often its secrets, and the pipeline still exits 0. Comparing the archive&amp;rsquo;s listing with &lt;code&gt;podman unshare find&lt;/code&gt; takes a minute and shows the gap.&lt;/p&gt;&#10;&lt;p&gt;Run the &lt;code&gt;podman unshare find&lt;/code&gt; and &lt;code&gt;comm&lt;/code&gt; check above against your own latest backup and see what comes back.&lt;/p&gt;&#10;</description></item><item><title>No DHCP for an LXC container while iptables counts the packets as ACCEPT: firewalld rejects them in its own nftables table</title><link>https://eghuro.cz/posts/lxc-dhcp-iptables-accept-firewalld-reject/</link><pubDate>Tue, 13 Oct 2026 22:56:00 +0200</pubDate><guid>https://eghuro.cz/posts/lxc-dhcp-iptables-accept-firewalld-reject/</guid><description>&lt;p&gt;A new LXC container came up with an IPv6 address and no IPv4 address. According to &lt;code&gt;iptables&lt;/code&gt;, the host allowed DHCP from the container bridge, and the counter on that rule kept growing:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo iptables -L INPUT -v -n | grep dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0 0 ACCEPT tcp -- lxcbr0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 26 7956 ACCEPT udp -- lxcbr0 * 0.0.0.0/0 0.0.0.0/0 udp dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Twenty-six packets accepted, and dnsmasq, listening on that bridge, never logged a single DHCPDISCOVER.&lt;/p&gt;&#10;&lt;p&gt;With nftables, &lt;code&gt;accept&lt;/code&gt; only ends the chain it sits in. Any other base chain on the same hook still gets the packet. On this host, the next one belonged to firewalld, and firewalld rejected everything arriving on an interface that wasn&amp;rsquo;t in any of its zones.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13 host with the stock &lt;code&gt;lxc-net&lt;/code&gt; bridge: &lt;code&gt;lxcbr0&lt;/code&gt;, 10.0.3.0/24, dnsmasq at 10.0.3.1&lt;/li&gt;&#10;&lt;li&gt;firewalld 2.3.1 with &lt;code&gt;FirewallBackend=nftables&lt;/code&gt;, default zone &lt;code&gt;public&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;iptables 1.8.11, the nf_tables flavor Debian installs as &lt;code&gt;iptables&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;an unprivileged Debian 13 container running systemd-networkd with &lt;code&gt;DHCP=true&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;When lxc-net brings up the bridge, it adds its own iptables rules: ACCEPT for DNS and DHCP on &lt;code&gt;lxcbr0&lt;/code&gt;, ACCEPT for forwarding, and a MASQUERADE for the subnet. It knows nothing about firewalld.&lt;/p&gt;&#10;&#10;&lt;h2 id="following-the-packet"&gt;Following the packet &lt;/h2&gt;&#10;&lt;p&gt;The first visible symptom was the container&amp;rsquo;s bootstrap script, which spent nine minutes waiting on DNS lookups that timed out. From there, each check moved the problem one step along the path:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The container sent requests. With debug logging, systemd-networkd showed &lt;code&gt;DHCPv4 client: DISCOVER&lt;/code&gt; in the &lt;code&gt;selecting&lt;/code&gt; state.&lt;/li&gt;&#10;&lt;li&gt;The host received them. The ACCEPT counter went from 20 to 26 to 29 while I made the client retry with &lt;code&gt;networkctl reconfigure eth0&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;dnsmasq had the right socket open: &lt;code&gt;0.0.0.0%lxcbr0:67&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;dnsmasq received nothing. &lt;code&gt;strace&lt;/code&gt; showed it sitting in &lt;code&gt;poll()&lt;/code&gt;. During a retry, it read only an IPv6 router solicitation, which it answered. That is where the IPv6 address came from.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;AppArmor (no denials), reverse-path filtering (loose on the bridge), &lt;code&gt;br_netfilter&lt;/code&gt; (not loaded), leftover iptables-legacy tables (empty), and socket buffer drops (&lt;code&gt;RcvbufErrors&lt;/code&gt; 0) all came up clean. The host had no &lt;code&gt;tcpdump&lt;/code&gt;, and no &lt;code&gt;nft&lt;/code&gt; either:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo nft list ruleset&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo: nft: command not found&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That left &lt;code&gt;iptables&lt;/code&gt; as the only view of the firewall, and &lt;code&gt;iptables&lt;/code&gt; shows only the tables it created. So the packet passed an ACCEPT at the input hook and never reached the socket. Listing the running services found a second firewall:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;firewalld.service loaded active running firewalld - dynamic firewall daemon&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo firewall-cmd --get-zone-of-interface=lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;no zone&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="two-tables-on-one-hook"&gt;Two tables on one hook &lt;/h2&gt;&#10;&lt;p&gt;After &lt;code&gt;apt install nftables&lt;/code&gt; (the binary is enough; leave &lt;code&gt;nftables.service&lt;/code&gt; disabled), you can see both chains on the input hook. Trimmed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo nft list chains&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;table ip filter {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;chain INPUT {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type filter hook input priority filter; policy accept;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;table inet firewalld {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;chain filter_INPUT {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type filter hook input priority filter + 10; policy accept;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;ip filter&lt;/code&gt; holds lxc-net&amp;rsquo;s rules, written through iptables-nft. &lt;code&gt;inet firewalld&lt;/code&gt; runs right after it, and an interface that no zone claims falls through to the default zone:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chain filter_INPUT_POLICIES {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;jump filter_IN_policy_allow-host-ipv6&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;jump filter_IN_public&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;reject with icmpx admin-prohibited&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;public&lt;/code&gt; allowed SSH and the DHCPv6 client, but no DHCP server and no DNS, so the DISCOVER hit the final &lt;code&gt;reject&lt;/code&gt;. The zone chain also ends with &lt;code&gt;meta l4proto { icmp, ipv6-icmp } accept&lt;/code&gt;, which is why the router solicitation got through.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Put the bridge in a firewalld zone. &lt;code&gt;trusted&lt;/code&gt; accepts everything, including forwarded traffic, which the containers need for internet access:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --zone&lt;span class="o"&gt;=&lt;/span&gt;trusted --add-interface&lt;span class="o"&gt;=&lt;/span&gt;lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --permanent --zone&lt;span class="o"&gt;=&lt;/span&gt;trusted --add-interface&lt;span class="o"&gt;=&lt;/span&gt;lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Or with Ansible:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Trust the container bridge in firewalld&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.posix.firewalld&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;trusted&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;lxcbr0&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;permanent&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;immediate&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;enabled&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The container got its lease on the client&amp;rsquo;s next retry, without restarting anything. &lt;code&gt;trusted&lt;/code&gt; also lets containers reach every port on the host. If that&amp;rsquo;s too much, give the bridge a zone of its own that allows only DHCP, DNS, and forwarding. The same applies to any interface that another tool creates, such as a VPN tunnel.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-counter-lied"&gt;Why the counter lied &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains#Base_chain_priority"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;nftables wiki&lt;/a&gt;&#10; says that &amp;ldquo;an accept verdict &amp;hellip; isn&amp;rsquo;t necessarily final&amp;rdquo;, while &amp;ldquo;drops take immediate effect, with no further rules or chains being evaluated.&amp;rdquo; Base chains on one hook run in priority order. An accept ends one chain, and a drop or reject in any chain wins. When two firewalls share a host, the stricter one decides, and the permissive one keeps counting packets that never arrive.&lt;/p&gt;&#10;&lt;p&gt;Most of the time you won&amp;rsquo;t see the second table. &lt;code&gt;iptables -L&lt;/code&gt; lists only what iptables-nft created, and a host running firewalld may not have the &lt;code&gt;nft&lt;/code&gt; command at all. (That missing binary &lt;a&#10; href="https://eghuro.cz/posts/fail2ban-nft-not-found"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;also leaves fail2ban unable to ban anyone&lt;/a&gt;&#10;.)&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A counter on an ACCEPT rule only proves that the packet reached that rule. When an accepted packet still doesn&amp;rsquo;t arrive, list every base chain on the hook and find the later one that drops it.&lt;/p&gt;&#10;&lt;p&gt;If your host runs firewalld alongside anything that writes its own iptables rules, run &lt;code&gt;sudo nft list chains | grep hook&lt;/code&gt; and count the chains on &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;forward&lt;/code&gt;.&lt;/p&gt;&#10;</description></item></channel></rss>