香港服务器运行 Debian 11,我怎样用 AppArmor + 内核 namespaces 把多租户隔离到“该看不见的都看不见”

夜里 2:17,机房湿度报警刚停,值班电话又响了:某租户的 PHP-FPM 在扫网段,看上去像把同机房的 Redis 都当成了自家人。我在荃湾机柜前蹲着,一边喝了口凉咖啡,一边盯着 nft monitor 的输出,心里想的其实很简单——如果隔离做到了“系统级”,这事儿就该只惊动那个容器的日志,而不是把整台物理机都弄得鸡犬不宁。
这篇文章是我在香港机房(BGP 多线,10G 上联)给一台跑 Debian 11(bullseye)的物理服务器做多租户安全隔离的完整实操记录。核心思路:Linux namespaces(PID/NET/MNT/UTS/IPC/USER/CGROUP)做边界,AppArmor 做最小权限的“手铐”,再配合 cgroup v2 与 nftables。写得尽量细,既讲“为什么”,也给出“怎么做”,以及我在现场踩过的坑。
基础环境与硬件参数
| 项目 | 规格 |
|---|---|
| 机房 | 香港荃湾,双路市电 + UPS + 发电,BGP 多线 |
| 服务器 | Dell R6525(单路 AMD EPYC 7402P,24C/48T,基频 2.8GHz) |
| 内存 | 256GB ECC DDR4-3200 |
| 系统盘 | 2 × 480GB SATA SSD(RAID1,系统) |
| 数据盘 | 2 × 1.92TB NVMe U.2(RAID1,业务) |
| 网卡 | 2 × 10GbE(Bonding LACP -> br0) |
| OS | Debian 11(内核 5.10 LTS),systemd 247,cgroup v2(统一层级)启用 |
| 目标 | 多租户(每租户 1~N 个容器/轻量实例),进程/网络/文件/能力隔离,资源可限额 |
威胁模型与安全目标
- 误配置/越权访问:某租户程序错误访问其他租户数据或宿主敏感目录。
- 进程逃逸/提权:利用 setuid、capabilities 或挂载点漏洞越权。
- 横向移动/内网扫描:租户进程扫描同机/同网段。
- 资源滥用:CPU、内存、IO 被少数实例占满。
目标:即便某个租户完全“失控”,也只能在自己的命名空间里折腾;AppArmor 让它只拿到“刚好够用”的文件和系统调用权限;cgroup 保证资源调度的可预测;nftables 将网络边界收紧。
方案总览(一图胜千言)
命名空间:每租户独立 PID/NET/MNT/UTS/IPC/USER/CGROUP。
AppArmor:对租户可执行文件(如 /usr/sbin/nginx、应用启动器)套上 profile,限制文件系统、capabilities、网络族等。
cgroup v2:CPU/内存/IO 限额。
nftables:租户子网出入站规则,默认最小开放。
实现载体:**LXC(首选)**或 systemd-nspawn。LXC 自带成熟的 AppArmor 集成,调优空间大;nspawn 更轻,用于演示底层原理也很直观。
我最终在生产里选 LXC(非特权容器),同时保留一套 nspawn + unshare 的“底层演示”脚本,便于新人理解 namespaces。
0. 系统准备
# 0.1 确保 AppArmor 与工具
apt update
apt install -y apparmor apparmor-utils apparmor-profiles apparmor-profiles-extra
# 0.2 确保开机启用(Debian 11 默认启用,但建议明确)
grep -q 'apparmor=1' /etc/default/grub || \
sed -i 's/^GRUB_CMDLINE_LINUX="/GRUB_CMDLINE_LINUX="apparmor=1 security=apparmor /' /etc/default/grub
update-grub
systemctl enable --now apparmor
# 0.3 核验状态
aa-status
cat /sys/module/apparmor/parameters/enabled # 预期: "Y"
# 0.4 cgroup v2(Debian 11 默认是 unified)
cat /sys/fs/cgroup/cgroup.controllers # 有值表示 v2 生效
# 0.5 nftables(统一到 nft,避免 iptables-legacy 冲突)
apt install -y nftables
update-alternatives --set iptables /usr/sbin/iptables-nft
systemctl enable --now nftables
1. 网络与存储基线
1.1 bridge 与上联(systemd-networkd 示例)
# /etc/systemd/network/br0.netdev
[NetDev]
Name=br0
Kind=bridge
# /etc/systemd/network/br0.network
[Match]
Name=br0
[Network]
Address=10.66.0.1/24
Gateway=10.66.0.254
DNS=1.1.1.1
# /etc/systemd/network/bond0.network -> 绑定物理口,桥接到 br0
[Match]
Name=bond0
[Network]
Bridge=br0
如果是单口,直接把 eno1 enslave 到 br0 即可。生产上我用 bond0(LACP)连到 ToR 交换机,桥内再挂各租户 veth。
1.2 存储布局
系统:RAID1(SATA SSD),ext4。
业务卷:RAID1(NVMe),/srv/tenants 下为每租户单独子卷/子目录。
建议:把每租户 rootfs 放在独立子卷,便于快照/回滚。
2. 用 LXC 构建“命名空间 + AppArmor + cgroup”的租户单元
我们用 非特权容器(unprivileged),这样即便容器内是“root”,在宿主上也只是一个映射后的普通 uid/gid。
2.1 安装与子 UID/GID 映射
apt install -y lxc lxc-templates uidmap
# 为 root(或特定宿主用户)分配 subuid/subgid
echo "root:100000:65536" >> /etc/subuid
echo "root:100000:65536" >> /etc/subgid
# 开启用户命名空间(若关闭的话)
sysctl kernel.unprivileged_userns_clone=1
echo "kernel.unprivileged_userns_clone=1" > /etc/sysctl.d/99-userns.conf
2.2 创建租户容器(以 tenant-a 为例)
lxc-create -n tenant-a -t download -- -d debian -r bullseye -a amd64
LXC 会生成配置 /var/lib/lxc/tenant-a/config。我们把关键隔离、网络、cgroup、AppArmor 一次写好:
# /var/lib/lxc/tenant-a/config
lxc.include = /usr/share/lxc/config/common.conf
lxc.include = /usr/share/lxc/config/userns.conf
# 非特权映射(root@container -> 100000@host 起)
lxc.idmap = u 0 100000 65536
lxc.idmap = g 0 100000 65536
# 网络:veth 接到 br0,固定 MAC 方便做 ACL
lxc.net.0.type = veth
lxc.net.0.link = br0
lxc.net.0.hwaddr = 00:16:3e:a0:01:01
lxc.net.0.name = eth0
# 命名空间(LXC 默认已启用 PID/MNT/UTS/IPC/NET/USER/CGROUP)
# 下面是能力收敛(Capabilities)
lxc.cap.drop = sys_admin sys_module sys_time sys_rawio mknod mac_admin mac_override audit_control audit_read
lxc.cap.drop = net_admin net_raw # 不允许容器内自行改网或构造 raw 包
# AppArmor:使用更严格的 profile(下面自定义)
lxc.apparmor.profile = lxc-container-tenantA
# 只读系统关键挂载
lxc.mount.auto = proc:mixed sys:ro cgroup:ro
# cgroup v2 资源限制(CPU 2核额度、4G 内存、IO 节流)
lxc.cgroup2.cpu.max = 200000 100000 # 200% = 2 cores 额度
lxc.cgroup2.memory.max = 4G
# IO 节流(示例,按设备更改):读 100MB/s,写 50MB/s
# 可用 lsblk -o NAME,MAJ:MIN 找 major:minor
lxc.cgroup2.io.max = "8:0 rbps=104857600 wbps=52428800"
lxc.cap.drop 比较关键:net_admin/net_raw 去掉后,容器内既不能随便改自己的路由/防火墙,也不能自由发 raw 包(比如自组 ICMP/ARP 扫描),安全边界会硬很多。
2.3 自定义 AppArmor profile(针对容器进程)
LXC 自带 lxc-container-default(-cgns),但我更喜欢按租户业务再收紧:
# /etc/apparmor.d/lxc/lxc-container-tenantA
# 基于 lxc 默认抽象,自定义更严策略
profile lxc-container-tenantA flags=(attach_disconnected,mediate_deleted) {
# 引入容器基础抽象
# Debian 的路径可能是 abstractions/lxc/container-base(随包版本略有差异)
# 不确定时可以参考 /etc/apparmor.d/lxc/lxc-default 或 lxc-container-default-cgns
#include <abstractions/lxc/container-base>
# 允许容器内常见可执行文件的读取与执行
/bin/** mr,
/usr/bin/** mr,
/sbin/** mr,
/usr/sbin/** mr,
# 限制写:只允许 /var /run /tmp /srv/tenant-data
deny /** wklx,
owner /var/** rwkl,
owner /run/** rwkl,
owner /tmp/** rwkl,
owner /srv/tenant-data/** rwkl,
# 网络族:只允许 IPv4/IPv6 TCP/UDP,不允许 raw
network inet stream,
network inet dgram,
network inet6 stream,
network inet6 dgram,
deny network raw,
# 能力:与 lxc.cap.drop 对齐,再次收紧(双保险)
deny capability sys_admin,
deny capability net_admin,
deny capability net_raw,
deny capability sys_module,
deny capability mknod,
# 挂载:只允许 proc 与必要的 tmpfs
mount fstype=proc -> /proc/,
mount fstype=tmpfs -> /run/,
}
应用并启用:
apparmor_parser -r /etc/apparmor.d/lxc/lxc-container-tenantA
aa-status | grep tenantA # 验证已加载
调试技巧:先 aa-complain lxc-container-tenantA 观察日志(journalctl -k | grep DENIED),确认不误杀,再 aa-enforce。
2.4 启动容器与基础配置
lxc-start -n tenant-a -d
lxc-attach -n tenant-a -- bash -lc 'printf "auto eth0\niface eth0 inet static\n address 10.66.0.11/24\ngateway 10.66.0.1\n" > /etc/network/interfaces.d/eth0; systemctl restart networking'
# 准备租户数据目录(在宿主)
mkdir -p /srv/tenants/tenant-a/data
chown -R 100000:100000 /srv/tenants/tenant-a # 映射 uid 对齐
网络 ACL(宿主 nftables):默认禁止向外部 25/135-139/445 等风险端口;只放行 http/https + 业务端口。
nft add table inet tenant
nft add chain inet tenant forward { type filter hook forward priority 0; policy drop; }
# 宿主 br0 -> 外网,租户子网 10.66.0.0/24
nft add rule inet tenant forward iif "br0" ip saddr 10.66.0.0/24 tcp dport {80,443,22,5432} accept
nft add rule inet tenant forward iif "br0" ip saddr 10.66.0.0/24 udp dport {53} accept
nft add rule inet tenant forward iif "br0" ip saddr 10.66.0.0/24 tcp dport {25,135-139,445} drop
nft add rule inet tenant forward ct state established,related accept
2.5 核验命名空间与资源
# 找到容器 init 的 PID
lxc-info -n tenant-a -pH
# 查看该 PID 拥有的 namespaces
lsns -p <PID>
# 查看 cgroup 限额生效
cat /sys/fs/cgroup/lxc.payload.tenant-a/cpu.max
cat /sys/fs/cgroup/lxc.payload.tenant-a/memory.max
3.(可选)用 systemd-nspawn/unshare 拿“原理感”
很多同事培训时,我会先用 unshare 拉个“赤裸”的隔离壳,直观看到每个 namespace 的作用:
# 准备最小 rootfs(debootstrap)
apt install -y debootstrap
debootstrap --variant=minbase bullseye /srv/tenants/demo-rootfs http://deb.debian.org/debian
# 进入独立命名空间(用户/网络/挂载/IPC/UTS/PID),把自己映射为 root
unshare --user --map-root-user --mount --uts --ipc --pid --net --fork --mount-proc \
chroot /srv/tenants/demo-rootfs /bin/bash
# 你现在看到的 /proc、主机名、进程树、网卡,都与宿主隔离
hostname demo-ns
ip link add veth-host type veth peer name veth-ns
# 把 veth-ns 放进刚才的 netns(方法之一:nsenter -t <PID> -n 执行,或 ip netns)
或用 systemd-nspawn 一条命令起容器:
apt install -y systemd-container
mkdir -p /var/lib/machines/tenant-b
debootstrap bullseye /var/lib/machines/tenant-b http://deb.debian.org/debian
systemd-nspawn -M tenant-b -D /var/lib/machines/tenant-b --private-network --capability= -U
# -U 使用 userns,把容器 root 映射成宿主非特权 uid
nspawn 的 AppArmor 也能用 aa-exec -p profile -- systemd-nspawn ... 套上,但在大规模多租户落地上,LXC 自带的 AppArmor + userns + cgroup 组合更“工程化”。
4. 运行一个真实业务:Nginx + PHP-FPM(读写最小化)
容器内(tenant-a):
apt update && apt install -y nginx php-fpm
mkdir -p /srv/tenant-data/site1
chown -R www-data:www-data /srv/tenant-data
Nginx 站点仅能读取 /srv/tenant-data/site1,AppArmor 已限制写路径,仅 owner /srv/tenant-data/** rwkl。
结合 lxc.cap.drop,nginx 就算被利用,也没法随意挂载/改路由/发 raw 包。
5. 监控与审计
- aa-status / journalctl -k | grep DENIED:审计 AppArmor 拒绝。
- nft list ruleset / nft monitor:观察流量命中。
- systemd-cgtop / /sys/fs/cgroup/...:看各容器资源。
- lsns / readlink /proc/$PID/ns/*:命名空间核验。
6. 我踩过的坑 & 解决手记
| 症状 | 根因 | 解决 |
|---|---|---|
lxc-start 报 Failed to create user namespace: No space left on device |
/etc/subuid//etc/subgid 未配置或额度不够 |
为运行 LXC 的用户(或 root)增加 100000:65536 段,必要时扩容 |
容器内无法 ping |
net_raw 能力被 drop,ICMP 需要 raw |
设计上不建议放开;若业务需要,单独为该租户放宽(权衡后改 lxc.cap.drop) |
容器内服务写不了 /var/log |
AppArmor deny 了写 | aa-complain 观察日志后,谨慎增加 owner /var/log/** rwkl |
mount 相关 DENIED |
Profile 中未允许特定挂载 | 尽量避免容器内自行挂载;必要时添加 mount fstype=proc/tmpfs 类规则 |
宿主 iptables-legacy 与 nft 冲突 |
混用导致规则未生效 | 统一用 nft:update-alternatives --set iptables /usr/sbin/iptables-nft |
| cgroup v2 控制项“不存在” | 混合层级或未挂载 | Debian 11 默认 v2,核对 /sys/fs/cgroup,并使用 lxc.cgroup2.* 项 |
7. 基准与效果(现场数据)
| 指标 | 未隔离(宿主直跑) | LXC + AppArmor + cgroup 后 |
|---|---|---|
| 租户互相可见性(/proc) | 可见全部进程 | 仅见自己 PID |
| 任意绑定低端口(<1024) | 可 | 不可(无 CAP_NET_BIND_SERVICE) |
| 横向扫描(raw/ARP/ICMP flood) | 可 | 默认不可(drop net_raw) |
| 文件写入越界 | 容易误写 | 仅 owner 的 /var /run /tmp /srv/tenant-data 可写 |
| CPU 抢占 | 无上限 | 按 cgroup 限额 |
| 调优/维护复杂度 | 低 | 中(有学习曲线,但可模板化) |
8. 最后把它“模板化”(我实际的落地做法)
一个 Ansible 角色 生成:
- /var/lib/lxc/<tenant>/config(带 cap、cgroup、net、AppArmor)
- /etc/apparmor.d/lxc/lxc-container-<tenant>(业务特定写路径)
- nft ACL(基于租户子网/IP 白名单)
每开新租户:ansible-playbook -e tenant=foo -e ip=10.66.0.23,3 分钟上线。
半年复盘:无一例跨租户访问;有 3 次因为 AppArmor DENIED 提醒我们修 Nginx 临时写路径,提前消除了风险。
尾声:一次多租户“越界”被隔离的现场记录
那次夜里“网段扫描”事件,流量刚冒头就被 nft 拦在桥上;容器里日志堆满了 AppArmor 的 DENIED。我远程把那个租户切到只读快照,客户第二天自己也能复原现场。
机房里风扇呼呼地吹,我把咖啡杯放回机柜门上,心里踏实:不是因为我多聪明,而是系统层的隔离足够“笨拙”——每一步都要先举手申请,没批就动不了。这种安全,才配得上多租户。
可复用的检查清单(给你拿去就用)
- aa-status 显示自定义 profile 已加载并 Enforce
- lxc.cap.drop 至少包含 sys_admin, net_admin, net_raw, sys_module
- lxc.cgroup2.cpu.max / memory.max / io.max 按配额设置
- nftables 有默认拒绝 + 业务白名单
- /etc/subuid /etc/subgid 正确配置
- lsns 验证 PID/NET/MNT/UTS/IPC/USER/CGROUP 全部隔离
- 只读挂载 sysfs,proc 使用混合/限制
- 租户写路径显式列白,其他写一律 deny
如果你已经在 Debian 11 的香港机器上按照上面的步骤跑起来了,下一步就把它自动化吧。把“正确姿势”固化为模板,才是能让凌晨 2 点也能稳的真正原因。