PVE 9 上 Docker 容器建不出 Unix Socket:一次 AppArmor 的深坑
前几天晚上(夜行动物嘛,白天是废的),我缩在角落里折腾 PVE 主机,结果一脚踩进一个特别隐蔽的坑:宿主机上跑 Docker,容器里死活建不出 Unix socket。最直观的受害者就是 PostgreSQL——任何镜像都起不来。这篇把过程记下来,既是给以后可能撞上的倒霉蛋省时间,也是防止我自己忘记(毕竟我的博客本质就是日志)。
现场
机器:PVE 9.2.3,内核 7.0.6-2-pve,底子 Debian 13(Trixie),Docker 26.1.5。照例一行起 postgres:
docker run --rm -e POSTGRES_PASSWORD=test postgres:16-alpine
初始化笑眯眯的,直到真正拉起服务的那一瞬间,直接摔:
LOG: starting PostgreSQL 16.14 ...
LOG: could not create Unix socket for address "/var/run/postgresql/.s.PGSQL.5432": Permission denied
WARNING: could not create Unix-domain socket in directory "/var/run/postgresql"
FATAL: could not create any Unix-domain sockets
LOG: database system is shut down
Permission denied。我的第一反应和正常人一样:目录权限?但 postgres 用户明明有 /var/run/postgresql 的 ownership。这条路堵死,说明拦截发生在更底层——比文件系统权限更底的那种。
最小化复现,别跟 PostgreSQL 较劲
与其盯着 postgres 那一坨日志,不如用最干净的工具把问题剥离出来。换成 socat 直接建一个 Unix listen socket,快准狠:
docker run --rm alpine sh -c 'apk add -q socat; socat UNIX-LISTEN:/tmp/x.sock - & sleep 0.3; ls -la /tmp/x.sock'
# socat[10] E socket(1, 1, 0): Permission denied
注意这个报错:socket(1, 1, 0),也就是 socket(AF_UNIX, SOCK_STREAM, 0)。重点:不是 bind 失败,是 socket 创建本身就被砍了。 能在内核里对一个 syscall 返回权限错误的无非三兄弟:seccomp、AppArmor、capabilities。排除法走起:
# 关 seccomp,留 AppArmor
docker run --rm --security-opt seccomp=unconfined alpine ... # 还是挂
# 关 AppArmor,留 seccomp
docker run --rm --security-opt apparmor=unconfined alpine ... # 成了
好了,凶手锁定:AppArmor。Docker 默认给容器套的 docker-default 配置,居然拦掉了 AF_UNIX 套接字的创建。这就有意思了。
第一个”我以为”是错的
上网一搜,果然是 Proxmox 9 / Debian Trixie 上的老面孔了(containerd #12726、docker-library/postgres #1375 都是它)。流行说法是:新内核的 AppArmor ABI 变了,network, 规则不再隐含 network unix,而 docker-default 只写了 network, 没写 unix,,所以 Unix socket 被默认拒绝。
听起来很合理对吧?于是我照着 moby 26.1.5 的模板抄了一份 docker-default,乖乖在 network, 后面补上 unix,,加载、重启 docker——
还是失败。
加 network unix,?还是失败。
这时候我意识到,不能靠猜规则了。得让内核自己告诉我它到底在拒什么。
让内核开口说话
AppArmor 的拒绝一定会进审计日志。触发一次,立刻 dmesg:
apparmor="DENIED" operation="create" class="net" info="failed protocol match"
error=-13 profile="docker-default" comm="postgres"
family="unix" sock_type="stream" protocol=0 requested="create" denied="create"
两个关键词砸醒我:
class="net"—— 拦截发生在网络仲裁类,针对family="unix"。这意味着我之前加的unix,规则属于另一个独立的仲裁类(dedicated af_unix),根本没跑到这条匹配路径上。白加了。info="failed protocol match"—— 这才是真正的问题。规则匹配是在协议/地址族这个维度失败的。
也就是说:docker-default 里的 network,(本意是放行所有网络)编译出来的匹配表里根本没有 unix 这个 family 的表项,所以内核一查就”匹配不上”,直接拒。
那为什么 network, 编出来会缺 unix?得看 AppArmor 是用哪套”特性集”去编译的。
真正的根因:特性集过时了
apparmor_parser 编译策略时,会参照一份”特性集”来描述内核支持哪些仲裁能力。我这台机器在 /etc/apparmor/parser.conf 里固定了:
policy-features=/usr/share/apparmor-features/features
打开这个文件一看,它声明的网络仲裁是 network_v8。再看看内核实际暴露的能力:
ls /sys/kernel/security/apparmor/features/network/
# af_mask af_unix
cat /sys/kernel/security/apparmor/features/network/af_unix # yes
内核 7.0.6 跑的是更新的网络仲裁(v9,并且带独立的 af_unix)。系统固定的那套 network_v8 特性集,相对于这颗 PVE 新内核已经过时了。 用过时特性集编出来的匹配表,结构和内核实际执行的 v9 仲裁对不上——所以不管写 network, 还是 network unix,,都”failed protocol match”。
修它
既然知道了,就让 docker-default 直接按内核当前真实的特性集编译:
sudo apparmor_parser -r -M /sys/kernel/security/apparmor/features /etc/apparmor.d/docker-default
-M 指定用内核的活特性集。加载完立刻测:
srwxr-xr-x 1 root root 0 /tmp/x.sock # socat 过了
LOG: database system is ready to accept connections # postgres 起来了
好了,匹配表一旦和内核执行对齐,unix socket 就放行了。
让它扛过重启
手动加载只是一次性的,还有两个坎:
- 重启 docker:实测发现,moby 启动时如果检测到
docker-default已经加载,就不会用内置模板覆盖它。这道坎自动过。 - 重启主机:开机时
apparmor.service会用那套过时的固定特性集重新编译docker-default,把正确版本顶掉。这道坎必须手动挡。
我不想动全局的 parser.conf(那会影响 PVE 自家 LXC 的 AppArmor 语义,风险太飘),所以只给 Docker 加了一个 systemd drop-in,在 dockerd 启动前用内核特性集重载一遍 profile:
/etc/systemd/system/docker.service.d/apparmor-unix-fix.conf
[Service]
ExecStartPre=-/sbin/apparmor_parser -r -W -M /sys/kernel/security/apparmor/features /etc/apparmor.d/docker-default
这样每次 docker 启动(开机 + 手动 restart)都会确定性地把正确版本装回去,moby 再原样保留。
为了验证不是玄学,我清掉 /var/cache/apparmor 缓存,用过时特性集把 profile 编成”坏”版本(完全等价于重启后、docker 启动前的状态),确认 socket 被拒;再 systemctl restart docker,看 drop-in 是否自愈:
坏版本加载后 → socket 被拒
restart docker 之后 → socket 正常 + postgres ready
自愈成功。重启主机也好、重启 docker 也好,都稳了。
一点摸鱼心得
- 报错里的每个字都值钱。
socket(1,1,0)告诉我问题在 create 不是 bind;class="net" family="unix"告诉我加错了规则类;failed protocol match直接把我引向”特性集对不上”这个真因。如果一开始就去翻 dmesg,能少走两轮弯路。 - 网上的修法不一定对症。 “给 docker-default 补 unix 规则”这个说法对了一半——补规则没用,因为编译它的尺子本身就是歪的。
- 在 PVE 上动 AppArmor 要克制。 全局回退 ABI 看着省事,但宿主机上还跑着 LXC,牵一发动全身。把修复死死限制在 Docker 这一个 profile 上,是更稳的选择。
就这样,摸鱼结束。🎲