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 引擎在后台为你做了类似上面手动实验的事情:

  1. 创建一个新的网络 Namespace。
  2. 创建一对 veth pair
  3. veth pair 的一端(例如 eth0)放入容器的网络 Namespace,并配置 IP 地址(通常来自 docker0 网桥的网段)。
  4. veth pair 的另一端(在宿主机上通常显示为 vethxxxxxx)连接到 Docker 网桥(默认为 docker0)。
  5. 配置容器内的默认网关指向 docker0 网桥的 IP 地址。
  6. 宿主机通过 docker0 网桥和 iptables NAT 规则,使得容器可以访问外部网络。

让我们启动一个容器看看:

# 启动一个简单的容器
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 pairdocker0 网桥与外界通信。这就是网络 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/

你会看到,c1c2net 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_uscpu.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)实现容器化的核心基石。

为什么理解这些底层机制很重要?

  1. 更好的故障排查: 当容器出现网络问题、性能瓶颈或资源异常时,理解 Namespace 和 Cgroups 能帮助你更快地定位问题根源(是隔离配置问题还是资源限制问题?)。
  2. 更优的资源规划: 了解资源限制的原理,可以更精确地为容器分配合适的 CPU 和内存,提高资源利用率。
  3. 更强的安全意识: 知道 User Namespace 等隔离机制,有助于理解容器的潜在安全风险和加固方法。
  4. 更深入的技术视野: 跳出 Docker 命令本身,理解其背后的 Linux 内核机制,有助于你更好地理解整个云原生生态系统(如 Kubernetes 对这些机制的运用)。

总结

Docker 的“魔法”并非凭空而来,它巧妙地站在了 Linux 内核巨人(Namespace 和 Cgroups)的肩膀上。通过 Namespace 实现环境隔离,通过 Cgroups 实现资源控制,Docker 为我们提供了一个强大而易用的容器化平台。

希望通过今天的分享,大家对 Docker 的底层原理有了更清晰的认识。下次当你运行 docker run 时,或许能想到背后发生的那些有趣的网络配置和资源限制操作!

如果你觉得这篇文章有帮助,欢迎点赞、分享给更多朋友!也欢迎在评论区留下你的问题或看法,我们一起交流学习!


posted on 2025-04-09 10:56  Leo-Yide  阅读(184)  评论(0)    收藏  举报