Ubuntu内核升级后AppArmor stacking拦截容器信号,被误判为网络故障
来源标题:An AppArmor Stacking Incident Misdiagnosed as a Network Failure
四台Ubuntu 26.04节点升级内核至7.0.0-42并重启后,多个命名空间的Pod陷入CrashLoop,应用报出no route to host等疑似网络错误。实际原因是内核升级后,containerd容器内的信号投递被AppArmor stacking拦截,而非网络问题。集群内ClusterIP出现no route to host是识别这一误判的关键信号。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
本文最后更新于:2026年11月4日上午
2026-10-29 18:08:56,四台 Ubuntu 26.04 节点完成 apt upgrade,linux-image-generic 从 7.0.0-38 升到 7.0.0-42,随后逐台重启。
重启之后,三个命名空间里的 Pod 接连陷入 CrashLoop:rsshub(TTRss,我的 RSS 阅读器;数据库是 PostgreSQL)、lobe-chat(LobeHub,一个 AI 应用;数据库是 PostgreSQL)、semaphore(第三方的 Ansible UI;数据库是 MySQL)。
应用报出的错误极具误导性:lobe-chat 里用于认证登录的 casdoor 报 dial tcp 10.43.36.163:5432: connect: no route to host,而 semaphore 则不停重复 Pinging database。
第一反应是网络问题。最终这个判断只对了一半。真正的问题是 Ubuntu 内核升级后,containerd 容器内的信号投递被 AppArmor stacking 拦截了。
第三点最容易让人误判。在宿主机上看到这些进程,会让人觉得这是宿主机层面的问题。
真正的信号是,no route to host 出现在一个集群内的 ClusterIP 上。
Ceph 全程保持 HEALTH_OK(存储没有问题),Service 的 EndpointSlices 也正常。
如果数据库没有 Ready,Service 就没有 endpoints,应用连不上只是下游症状。
在宿主机上,gosu mysql 和 gosu postgres 各自占用约 100% CPU,但 mysqld 和 postgres 都根本没有启动。
gosu 是官方镜像 entrypoint 用来降权用的。它卡住意味着容器始终没有 exec 到真正的数据库进程。这也解释了前面提到的数据库日志为空。
1 2 3 4 main.SetupUser -> syscall.Setgid -> syscall.AllThreadsSyscall -> runtime.osyield
strace 分析除了无尽的 sched_yield() 之外什么也没发现。换句话说,gosu 在等待一个永远不会到来的结果。
Go 的 AllThreadsSyscall 会向进程内的每个线程广播 SIGSETXID,用这个信号让每个线程同步完成 setgid/setuid。当信号投递失败时,等待循环就永远退不出来。
至此,怀疑对象从网络转向了「信号到底能不能投递」。
一个 kretprobe 给出了如下输出:
1 bpftrace -e 'kretprobe:apparmor_task_kill { printf("%s %d\n", comm, retval); }'
gosu 的返回值是 -13,即 EPERM,而且完全没有 signal_generate 事件。
直接在容器内验证:对同容器内的另一个进程执行 kill -TERM 返回 Permission denied,目标进程依然存活。
这证明 AppArmor 在中间拦截了信号。
该 task 实际的 AppArmor label 长这样:
1 cri-containerd.apparmor.d//&unconfined
//& 是 stacking 记号,意思是在 CRI profile 之上再叠加一层 unconfined。
containerd 的 CRI profile 里包含 signal ... peer=cri-containerd.apparmor.d 这类规则,依赖精确的 label 匹配来允许容器内的信号投递。一旦 label 变成 stacking 形式,就匹配不上了。
在内核 7.0.0-42 和默认 sysctl 参数 kernel.apparmor_restrict_unprivileged_unconfined=1 下,容器 profile 的转换方式被改成了 stacking。结果就是:容器内任何信号投递都返回 EPERM。
📝备注:gosu 并不是唯一的受害者。glibc 的多线程 setuid 广播、postgres 自身的 backend 信号,走的都是同一条路径。这也解释了为什么是「数据库容器全部起不来」,而不是某个特定镜像的问题。
同一节点、同一个 mysql:8 镜像,只改容器配置:
前三行是仅用于取证验证的 workaround。第四行说明这件事和 capabilities 无关。
修复之后,ClusterIP 和 PodIP 都可达了。
1 sysctl -w kernel.apparmor_restrict_unprivileged_unconfined=0
把这一行加进 sysctl 循环并持久化到:
1 /etc/sysctl.d/90-homelab-prerequisites.conf
文件名里的 90- 是有意为之。Ubuntu 自带的 /usr/lib/sysctl.d/10-apparmor.conf 排序更靠前,所以只有排序更靠后的 90- 文件才能覆盖它。
AppArmor label 在容器创建的那一刻就固定了;改 sysctl 不会让已经在运行的容器自愈。
这次我重建了 lobe-chat/{postgresql,casdoor,lobe}、semaphore/{semaphore-mysql,semaphore}、rsshub/database-postgres;其余的 Pod 就让它们自然滚动。
关闭这个限制相当于回滚了 Ubuntu 的一项加固措施。集群里的容器不使用非特权 userns,所以暴露面有限,短期内可以接受。等内核修复后应重新评估。
7.0.0-42 里的 stacking 行为与 containerd 的 CRI profile 中的 signal 规则不兼容。Ubuntu 和 containerd 都已收到这个反馈,containerd 大概率会修复;今后我会每月查看 containerd 和 k3s 的 changelog。相关 bug 报告:
Ubuntu 内核升级后,容器内的信号投递被 AppArmor stacking 拦截:gosu 空转、数据库日志为空、应用报 no route to host。这篇文章记录了从进程表和 perf,到 strace,再到 bpftrace 的排查过程,以及修复方式——持久化 sysctl 并重建 Pod——并会继续跟踪上游修复。
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。