Docker 底层之: Namespace 和 Cgroups 的隔离与限制
作为开发者或运维工程师,我们每天都在和 Docker 打交道。它轻量、快速、便捷,极大地改变了我们构建、部署和运行应用的方式。但你是否好奇,Docker 是如何施展“魔法”,让小小的容器拥有独立的文件系统、网络、进程空间,并且还能限制它们的资源使用呢?
答案就藏在 Linux 内核的两个强大特性中:Namespace(命名空间) 和 Cgroups(控制组)。今天,就让我们一起揭开 Docker 底层的神秘面纱,深入探索这两个核心技术。
一、Namespace:构建容器的“独立王国”
早在 2007 年左右,Linux 内核就引入了 Namespace 技术。你可以把它想象成给系统资源(如进程、网络、文件系统等)加上了一层“隔离墙”。不同的 Namespace 就像不同的房间,每个房间里的进程看到的世界是独立的,互不干扰。
Linux 提供了多种类型的 Namespace 来隔离不同的系统资源:
- IPC (Inter-Process Communication): 隔离进程间通信所需的资源,如 System V IPC 对象、POSIX 消息队列。同一个 IPC Namespace 内的进程可以互相通信,不同 Namespace 间的进程则不能直接通信。
- MNT (Mount): 隔离文件系统挂载点。每个 MNT Namespace 都有自己独立的文件系统视图,包括根目录 (
/) 和其他挂载点。容器能拥有自己独立的文件系统,很大程度上就归功于 MNT Namespace。 - NET (Network): 隔离网络设备、IP 地址、端口、路由表、防火墙规则等网络资源。这是实现容器独立网络栈的关键。
- PID (Process ID): 隔离进程 ID。在不同的 PID Namespace 中,可以拥有相同的 PID。比如,每个容器都可以有自己的 PID 为 1 的“init”进程,而在宿主机上,它们的 PID 则是不同的。
- User (User ID): 隔离用户和用户组 ID。在一个 User Namespace 内部,进程可以拥有 root 权限(UID 0),但在外部宿主机上,它可能只是一个普通用户权限。这增强了容器的安全性。
- UTS (UNIX Time-sharing System): 隔离主机名(Hostname)和域名(Domain Name)。这使得每个容器可以拥有自己独立的主机名。
实战演练:手动创建和体验网络 Namespace
理论有点枯燥?让我们动手实践一下,手动创建一个网络 Namespace,直观感受它的隔离效果。
1. 创建网络 Namespace
# 创建一个名为 "oldboyedu-linux" 的网络 Namespace
sudo ip netns add oldboyedu-linux
# 查看 Namespace 文件(它实际上是一个挂载点,用于保持 Namespace 活跃)
ls -l /var/run/netns/oldboyedu-linux
# 在新 Namespace 内执行命令,查看网络接口(刚创建时只有 lo)
sudo ip netns exec oldboyedu-linux ip a
# 输出类似:
# 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
# link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2. 激活 Namespace 内的 lo 网卡
默认情况下,新 Namespace 里的 lo (loopback) 网卡是 DOWN 状态。
# 尝试在新 Namespace 内 ping 自己,会失败
# sudo ip netns exec oldboyedu-linux ping 127.0.0.1
# 激活 lo 网卡
sudo ip netns exec oldboyedu-linux ip link set lo up
# 或者使用 ifconfig (如果 Namespace 内有该命令)
# sudo ip netns exec oldboyedu-linux ifconfig lo up
# 再次查看网络接口,lo 状态变为 UNKNOWN 或 UP
sudo ip netns exec oldboyedu-linux ip a
# 再次 ping 自己,现在应该成功了
sudo ip netns exec oldboyedu-linux ping 127.0.0.1 -c 4
3. 创建 veth pair 连接宿主机和 Namespace
veth pair 是一种虚拟的网络设备对,像一根网线,一端连接宿主机(或另一个 Namespace),另一端连接我们的目标 Namespace。
# 创建一对 veth 设备,名为 veth-host 和 veth-ns
sudo ip link add veth-host type veth peer name veth-ns
# 在宿主机上查看,会多出 veth-host 和 veth-ns 两块网卡
ip a | grep veth
4. 将 veth 一端移入 Namespace
# 将 veth-ns 设备移动到 "oldboyedu-linux" Namespace 中
sudo ip link set veth-ns netns oldboyedu-linux
# 再次在宿主机上查看,veth-ns 消失了
ip a | grep veth-ns # 应该没有输出了
# 在 Namespace 内查看,veth-ns 出现了
sudo ip netns exec oldboyedu-linux ip a | grep veth-ns
5. 配置 IP 地址并启用 veth 设备
# 给 Namespace 内的 veth-ns 配置 IP 地址并启用
sudo ip netns exec oldboyedu-linux ip addr add 172.31.100.200/24 dev veth-ns
sudo ip netns exec oldboyedu-linux ip link set veth-ns up
# 在 Namespace 内查看 IP 配置
sudo ip netns exec oldboyedu-linux ip a show veth-ns
# 给宿主机的 veth-host 配置 IP 地址并启用 (注意要在同一网段)
sudo ip addr add 172.31.100.100/24 dev veth-host
sudo ip link set veth-host up
# 在宿主机上查看 IP 配置
ip a show veth-host
6. 测试连通性
# 在宿主机 ping Namespace 内的 veth-ns IP
ping 172.31.100.200 -c 4
# 在 Namespace 内 ping 宿主机的 veth-host IP
sudo ip netns exec oldboyedu-linux ping 172.31.100.100 -c 4
7. 配置 Namespace 默认网关(可选,用于访问外部网络)
现在,Namespace 只能和宿主机的 veth-host 通信。如果想让它访问宿主机所在的其他网络(比如宿主机的物理网卡 IP 10.0.0.101),需要配置默认网关。
# 尝试 ping 宿主机的物理网卡 IP(假设为 10.0.0.101),会失败
# sudo ip netns exec oldboyedu-linux ping 10.0.0.101
# 在 Namespace 内添加默认路由,网关指向宿主机的 veth-host IP
sudo ip netns exec oldboyedu-linux ip route add default via 172.31.100.100
# 再次 ping 宿主机的物理网卡 IP,现在应该可以通了!
# (前提是宿主机开启了 IP 转发: sysctl net.ipv4.ip_forward=1)
sudo ip netns exec oldboyedu-linux ping 10.0.0.101 -c 4
# 尝试 ping 外网 IP (如 114.114.114.114)
# 如果宿主机配置了 NAT 规则 (iptables),理论上可以通
# sudo ip netns exec oldboyedu-linux ping 114.114.114.114 -c 4
# (注意:ping 域名需要配置 DNS,这里建议先 ping IP 地址测试)
# 可以在宿主机抓包观察
# sudo tcpdump -i veth-host -nn icmp
8. 清理环境
# 删除 Namespace (这会自动删除内部的 veth-ns)
sudo ip netns del oldboyedu-linux
# 删除宿主机的 veth-host (veth-ns 已随 Namespace 删除)
# 注意:有时 veth-host 可能不会自动删除,需要手动处理
# 如果 ip netns del 成功,通常 veth pair 会一起消失
# 如果 veth-host 还在,可以手动删除:
# sudo ip link del veth-host
# 检查 Namespace 是否已删除
ls -l /var/run/netns/
通过这个手动实验,我们直观地看到了网络 Namespace 如何提供了一个隔离的网络环境,以及如何通过 veth pair 将其与外部(宿主机)连接起来。
Docker 如何运用网络 Namespace?
当你运行 docker run ... 启动一个容器时,Docker 引擎在后台为你做了类似上面手动实验的事情:
- 创建一个新的网络 Namespace。
- 创建一对
veth pair。 - 将
veth pair的一端(例如eth0)放入容器的网络 Namespace,并配置 IP 地址(通常来自docker0网桥的网段)。 - 将
veth pair的另一端(在宿主机上通常显示为vethxxxxxx)连接到 Docker 网桥(默认为docker0)。 - 配置容器内的默认网关指向
docker0网桥的 IP 地址。 - 宿主机通过
docker0网桥和iptablesNAT 规则,使得容器可以访问外部网络。
让我们启动一个容器看看:
# 启动一个简单的容器
docker run -d --name c1 nginx:alpine
# 查看宿主机的网络接口,注意 docker0 和 新出现的 veth 设备
ip a
# 查看容器内的网络接口
docker exec c1 ip a
# 你会看到容器内有一个 eth0@ifXX 设备,这个 ifXX 通常对应宿主机上 veth 设备的接口索引
# 容器内的 IP 地址通常是 172.17.x.x (默认 docker0 网段)
# 查看容器的网络配置(IP, 网关等)
docker exec c1 ifconfig
docker exec c1 route -n
你会发现,容器内的网络环境是独立的,它通过 Docker 自动配置的 veth pair 和 docker0 网桥与外界通信。这就是网络 Namespace 在 Docker 中的实际应用。
特殊场景:共享网络 Namespace
有时,我们希望多个容器共享同一个网络栈,例如 Kubernetes Pod 中的业务容器和 Sidecar 容器(如 Istio Envoy 代理)。Docker 允许通过 --network container:<容器名或ID> 来实现。
# 清理之前的容器
docker rm -f c1 2>/dev/null
# 启动第一个容器 c1
docker run -itd --name c1 busybox sh
# 启动第二个容器 c2,并指定共享 c1 的网络 Namespace
docker run -itd --name c2 --network container:c1 busybox sh
# 获取两个容器的宿主机进程 ID (PID)
PID_C1=$(docker inspect -f "{{ .State.Pid}}" c1)
PID_C2=$(docker inspect -f "{{ .State.Pid}}" c2)
echo "C1 PID: $PID_C1, C2 PID: $PID_C2"
# 查看两个容器的网络配置,你会发现它们完全一样!
docker exec c1 ip a
docker exec c2 ip a
docker exec c1 ifconfig
docker exec c2 ifconfig
# 检查两个进程对应的 Namespace 文件链接
# 注意观察 'net -> net:[xxxxx]' 这一行
echo "--- C1 Namespaces ---"
ls -l /proc/$PID_C1/ns/
echo "--- C2 Namespaces ---"
ls -l /proc/$PID_C2/ns/
你会看到,c1 和 c2 的 net Namespace 指向了同一个文件(net:[inode号] 相同),这意味着它们共享同一个网络接口、IP 地址和端口空间。但是,它们的 ipc, mnt, pid, uts Namespace 仍然是独立的(inode 号不同)。
思考: 共享网络 Namespace 有什么好处?
- 容器间可以通过
localhost直接通信,性能高。 - 一个容器可以监听端口,另一个容器(或宿主机)可以通过
localhost:<端口>访问。 - 适用于 Sidecar 模式,例如网络代理、日志收集器等。
注意: 共享网络 Namespace 也意味着端口冲突的风险,需要规划好端口使用。
二、Cgroups:掌控容器的资源命脉
有了 Namespace,容器有了自己独立运行的环境。但如果一个容器无限制地消耗 CPU、内存,就可能拖垮整个宿主机,影响其他容器。这时,Cgroups (Control Groups) 就派上用场了。
Cgroups 是 Linux 内核提供的另一种机制,用于限制、记录和隔离进程组(process groups)使用的物理资源,如 CPU、内存、磁盘 I/O、网络带宽等。
Cgroups 通过不同的子系统 (subsystem) 来管理不同的资源:
- cpu: 限制 CPU 使用率。可以通过
cpu.cfs_period_us(周期) 和cpu.cfs_quota_us(配额) 来控制。例如,quota=50000,period=100000表示最多使用 50% 的一个 CPU core。 - cpuacct: 统计 Cgroup 中进程的 CPU 使用情况。
- cpuset: 将进程绑定到特定的 CPU 核心和内存节点(NUMA 架构下)。
- memory: 限制内存使用量(包括物理内存和 swap)。可以通过
memory.limit_in_bytes设置硬限制,memory.soft_limit_in_bytes设置软限制。 - blkio: 限制块设备(如硬盘、SSD)的 I/O 速率。
- devices: 控制 Cgroup 中的进程能否访问某些设备。
- net_cls / net_prio: 用于网络流量的分类和优先级设置。
- freezer: 暂停 (suspend) 和恢复 (resume) Cgroup 中的所有进程。
- ...等等
实战演练:手动使用 Cgroup 限制 CPU
我们来手动体验一下如何用 Cgroup 限制一个进程的 CPU 使用。
1. 找到 Cgroup 文件系统挂载点
Cgroup 通常挂载在 /sys/fs/cgroup 目录下,不同的子系统有不同的目录。
mount | grep cgroup
# 你会看到类似 /sys/fs/cgroup/cpu, /sys/fs/cgroup/memory 等挂载点
2. 创建自定义 Cgroup
我们在 cpu 子系统下创建一个自己的控制组。
# 进入 cpu 子系统目录
cd /sys/fs/cgroup/cpu
# 创建一个名为 oldboyedu-limit 的 Cgroup
sudo mkdir oldboyedu-limit
ls oldboyedu-limit/
# 你会看到里面有很多控制文件,如 cpu.cfs_quota_us, tasks 等
3. 运行一个 CPU 密集型任务
我们需要一个能持续消耗 CPU 的程序来测试。stress 工具是个不错的选择。
# 安装 stress (Debian/Ubuntu)
# sudo apt update && sudo apt install -y stress
# 启动 stress,使用 4 个 worker 持续消耗 CPU
stress -c 4 &
# 记下 stress 主进程的 PID (可以通过 ps aux | grep stress 查找)
STRESS_PID=$!
echo "Stress PID: $STRESS_PID"
# 查看 CPU 使用情况(比如用 top 或 htop),你会看到 CPU 使用率很高
top
4. 设置 CPU 限制
假设我们想把 stress 进程的 CPU 使用率限制在 30% 左右(相对于一个 CPU 核心)。cpu.cfs_period_us 默认为 100000 (100ms),所以 quota 设为 30000 (30ms)。
# 设置 CPU 配额为 30000 微秒
sudo sh -c "echo 30000 > /sys/fs/cgroup/cpu/oldboyedu-limit/cpu.cfs_quota_us"
# 查看 period (通常是 100000)
cat /sys/fs/cgroup/cpu/oldboyedu-limit/cpu.cfs_period_us
5. 将进程加入 Cgroup
将 stress 进程及其 worker 子进程的 PID 加入到我们创建的 Cgroup 的 tasks 文件中。
# 查找 stress 及其子进程的 PID
ps -eo pid,comm | grep stress
# 将主进程 PID 加入 tasks 文件
sudo sh -c "echo $STRESS_PID > /sys/fs/cgroup/cpu/oldboyedu-limit/tasks"
# 将子进程 PID 也加入 (如果知道的话,或者直接将父进程加进去,子进程通常会继承)
# 也可以使用 pgrep -P $STRESS_PID 找到子进程
# for pid in $(pgrep -P $STRESS_PID); do sudo sh -c "echo $pid >> /sys/fs/cgroup/cpu/oldboyedu-limit/tasks"; done
# 再次观察 top 或 htop,你会发现 stress 进程的总 CPU 使用率被限制在 30% 左右了!
top
6. 清理
# 结束 stress 进程
kill $STRESS_PID
# 删除 Cgroup (需要确保 tasks 文件为空)
# Cgroup 中的进程结束后,会自动从 tasks 文件移除
# 确认 tasks 文件为空:cat /sys/fs/cgroup/cpu/oldboyedu-limit/tasks
# 删除目录
sudo rmdir /sys/fs/cgroup/cpu/oldboyedu-limit
这个手动实验展示了 Cgroups 如何通过简单的文件操作来限制进程的资源使用。
Docker 如何运用 Cgroups?
当你使用 docker run 并指定资源限制参数时,Docker 引擎就是在幕后操作 Cgroups 文件系统。
--cpus=<value>: 限制容器能使用的 CPU 核心数。例如--cpus="1.5"表示最多使用 1.5 个 CPU 核心的计算量。Docker 会自动计算cpu.cfs_period_us和cpu.cfs_quota_us。--cpu-quota=<value>: 直接设置cpu.cfs_quota_us。通常与--cpu-period(默认 100000) 配合使用。--cpu-quota=30000效果类似上面手动设置的 30%。-m或--memory=<value>: 限制容器能使用的最大内存量,例如-m 200m。Docker 会设置memory.limit_in_bytes。--memory-swap=<value>: 限制内存+Swap 的总使用量。通常设置为与-m相同的值来禁用 Swap,或者设置为-1表示不限制 Swap。--blkio-weight=<value>: 设置块 I/O 的相对权重(非绝对限制)。--device-read-bps,--device-write-bps: 限制特定设备的读写速率。- ...等等
让我们用 Docker 来实践资源限制:
1. 启动带限制和不带限制的容器
# 清理旧容器
docker rm -f c1 c2 2>/dev/null
# 启动容器 c1,限制 CPU 为 30% (quota=30000), 内存为 200MB
docker run -itd --name c1 --cpu-quota=30000 -m 200m busybox sh
# 注意:可能会有 Swap 相关的警告,如果内核不支持或 cgroup 未挂载 swap 子系统
# 启动容器 c2,不加任何资源限制
docker run -itd --name c2 busybox sh
2. 查看资源限制效果 (静态)
使用 docker stats 命令可以实时查看容器的资源使用情况和限制。--no-stream 只输出一次。
# 查看 c1 的限制 (CPU 限制不直接显示,但 MEM LIMIT 清晰可见)
docker stats c1 --no-stream
# 输出类似: MEM USAGE / LIMIT: X.XXMiB / 200MiB
# 查看 c2 的限制 (MEM LIMIT 是宿主机的总内存)
docker stats c2 --no-stream
# 输出类似: MEM USAGE / LIMIT: X.XXMiB / 3.81GiB (假设宿主机约 4G 内存)
3. 压力测试 CPU 限制
# 在 c1 容器内运行 stress,尝试使用 4 个 CPU
docker exec c1 stress -c 4 &
# 实时监控 c1 的资源使用
docker stats c1
# 你会看到 CPU % 被稳定地限制在 30% 左右 (可能会有小幅波动)
# 在 c2 容器内运行 stress (对比组)
# docker exec c2 stress -c 4 &
# docker stats c2 # CPU % 会飙升,可能达到 400% (如果宿主机有 4 核或更多)
# 清理 stress 进程
docker exec c1 killall stress
# docker exec c2 killall stress
4. 压力测试内存限制
# 在 c1 容器内运行 stress,尝试分配超过 200M 的内存
# --vm 2: 启动 2 个内存分配 worker
# --vm-bytes 128M: 每个 worker 尝试分配 128M (总共 256M > 200M 限制)
docker exec c1 stress --vm 2 --vm-bytes 128M &
# 实时监控 c1 的资源使用
docker stats c1
# 你会看到 MEM USAGE / LIMIT 迅速接近 200MiB / 200MiB
# MEM % 会达到接近 100%
# 如果内存压力过大,容器可能会被 OOM Killer (Out Of Memory Killer) 终止
# 清理 stress 进程
docker exec c1 killall stress
5. 动态调整运行中容器的资源限制
这是一个非常有用的特性!如果发现一个正在运行的容器资源不足或占用过多,可以使用 docker update 命令动态调整限制,无需重启容器。
# 假设 c2 正在运行高负载任务
docker exec c2 stress -c 4 &
# 查看 c2 当前资源使用 (未限制)
docker stats c2
# 动态给 c2 加上限制:CPU 20%, 内存 200M (并禁用 swap)
docker update --cpu-quota=20000 -m 200m --memory-swap=200m c2
# 再次查看 c2 资源使用
docker stats c2
# 你会看到 CPU % 被限制在 20% 左右,MEM USAGE 也被限制在 200M 以下
# (如果 stress 之前占用了超过 200M 内存,可能会被 OOM kill)
# 清理
docker exec c2 killall stress
docker rm -f c1 c2
通过 Docker 提供的简洁参数,我们可以方便地利用 Cgroups 的强大能力来管理容器资源,确保应用的稳定性和资源的合理分配。
三、Namespace + Cgroups:Docker 容器化的基石
现在我们明白了:
- Namespace 负责隔离,为容器创建独立的运行环境(网络、进程、文件系统等),让容器感觉自己像一台独立的机器。
- Cgroups 负责限制,控制容器能使用的资源(CPU、内存、I/O 等),防止单个容器耗尽宿主机资源,保证系统整体的稳定性和公平性。
这两项技术相辅相成,共同构成了 Docker(以及其他容器技术如 LXC、rkt)实现容器化的核心基石。
为什么理解这些底层机制很重要?
- 更好的故障排查: 当容器出现网络问题、性能瓶颈或资源异常时,理解 Namespace 和 Cgroups 能帮助你更快地定位问题根源(是隔离配置问题还是资源限制问题?)。
- 更优的资源规划: 了解资源限制的原理,可以更精确地为容器分配合适的 CPU 和内存,提高资源利用率。
- 更强的安全意识: 知道 User Namespace 等隔离机制,有助于理解容器的潜在安全风险和加固方法。
- 更深入的技术视野: 跳出 Docker 命令本身,理解其背后的 Linux 内核机制,有助于你更好地理解整个云原生生态系统(如 Kubernetes 对这些机制的运用)。
总结
Docker 的“魔法”并非凭空而来,它巧妙地站在了 Linux 内核巨人(Namespace 和 Cgroups)的肩膀上。通过 Namespace 实现环境隔离,通过 Cgroups 实现资源控制,Docker 为我们提供了一个强大而易用的容器化平台。
希望通过今天的分享,大家对 Docker 的底层原理有了更清晰的认识。下次当你运行 docker run 时,或许能想到背后发生的那些有趣的网络配置和资源限制操作!
如果你觉得这篇文章有帮助,欢迎点赞、分享给更多朋友!也欢迎在评论区留下你的问题或看法,我们一起交流学习!
浙公网安备 33010602011771号