阶段一 · Linux 与容器底层

资源隔离的基石

核心思想

Namespace 的作用是隔离视图--不同 Namespace 中的进程看到不同的系统资源(进程列表,网络设备,文件系统等),但它们实际上运行在同一个内核上.这就是容器"轻量级隔离"的根基.

Namespace 类型总览

Namespace 隔离内容 系统调用标志 内核版本
PID 进程 ID CLONE_NEWPID 2.6.24
Network 网络设备,IP,端口,路由表 CLONE_NEWNET 2.6.29
Mount 文件系统挂载点 CLONE_NEWNS 2.4.19
UTS 主机名和域名 CLONE_NEWUTS 2.6.19
IPC 进程间通信(信号量,消息队列,共享内存) CLONE_NEWIPC 2.6.19
User 用户和用户组 ID CLONE_NEWUSER 3.8
Cgroup Cgroup 根目录视图 CLONE_NEWCGROUP 4.6
Time 系统时钟 CLONE_NEWTIME 5.6

其中前六种是 Docker 容器使用的核心 Namespace,后两种(Cgroup / Time)较新,容器场景中使用较少.

PID Namespace - 进程隔离

  • 每个 PID Namespace 有独立的进程编号空间
  • 子 Namespace 中的第一个进程 PID 为 1(类似 init 进程)
  • 嵌套关系:父 Namespace 可以看到子 Namespace 的进程,反之不行
  • 容器中 ps aux 只能看到容器内的进程,就是 PID Namespace 的功劳

类比:PID Namespace 就像公寓楼的各个单元--每个单元内部的房间编号从 1 开始,互不冲突.你在自己单元里看不到隔壁的房间.

# 创建新的 PID Namespace 并在其中运行 bash
sudo unshare --pid --fork --mount-proc bash

# 在新 Namespace 中查看进程
ps aux
# 你会发现只有 bash 和 ps 两个进程,PID 从 1 开始

为什么需要 --fork?

进程的 PID Namespace 归属在它被创建(fork)的那一刻就固定了,之后不能更改--就像你出生在哪个国家,国籍就是哪个.unshare 进程本身属于旧 NS,它不能把自己"传送"到新 NS 里.加了 --fork 后,unshare 会 fork 一个子进程,这个子进程"出生"在新 NS 中,PID 为 1,然后由它来执行 bash.

为什么需要 --mount-proc?

ps 命令查看进程列表时,并不是直接询问内核内存,而是读取 /proc 这个特殊的虚拟文件系统(每个数字子目录对应一个进程,如 /proc/1/,/proc/1234/).如果不重新挂载,新 NS 里的 /proc 仍然是宿主机的那份,ps 看到的还是宿主机全部进程.--mount-proc 在新 NS 中重新挂载 /proc,让它只反映当前 NS 的进程.

Network Namespace - 网络隔离

每个 Network Namespace 拥有独立的:

  • 网络设备(网卡)
  • IP 地址
  • 端口范围
  • 路由表
  • iptables 规则
  • /proc/net 目录

容器间网络通信通常通过 veth pair(虚拟以太网对)+ bridge(网桥)实现(详见第 04 篇).

# 创建两个 Network Namespace
sudo ip netns add ns1
sudo ip netns add ns2

# 创建 veth pair(一对虚拟网线)
sudo ip link add veth1 type veth peer name veth2

# 将 veth 分别放入两个 Namespace
sudo ip link set veth1 netns ns1
sudo ip link set veth2 netns ns2

# 配置 IP 并启动
sudo ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth1
sudo ip netns exec ns1 ip link set veth1 up
sudo ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth2
sudo ip netns exec ns2 ip link set veth2 up

# 测试连通性
sudo ip netns exec ns1 ping 10.0.0.2

# 清理
sudo ip netns del ns1
sudo ip netns del ns2

关键理解

Docker 容器的网络隔离就是 Network Namespace 的直接应用.每个容器有自己的网络栈,通过 veth pair 连接到宿主机的 docker0 网桥,实现容器间和容器与外网的通信.

Mount Namespace - 文件系统隔离

  • 隔离文件系统挂载点,每个 Namespace 可以有独立的挂载视图
  • 最早实现 的 Namespace(2002 年,Linux 2.4.19)
  • Docker 利用 Mount Namespace 让容器拥有独立的根文件系统(rootfs)
# 创建新的 Mount Namespace
sudo unshare --mount bash

# 在新 Namespace 中挂载 tmpfs,不影响宿主机
mount -t tmpfs tmpfs /mnt
echo "hello from container" > /mnt/test.txt
cat /mnt/test.txt # 可以读到

# 退出后宿主机的 /mnt 不受影响

类比:Mount Namespace 就像给每个容器配了一套独立的"文件柜".容器里新放的文件,修改的文件,外面完全看不到,也不会影响其他容器的文件柜.

UTS Namespace - 主机名隔离

  • 隔离主机名(hostname)和域名(domainname)
  • 让每个容器可以有自己的主机名
  • UTS = Unix Timesharing System,历史命名
# 创建新的 UTS Namespace 并修改主机名
sudo unshare --uts bash
hostname my-container
hostname # 输出: my-container

# 退出后宿主机的 hostname 不受影响

IPC Namespace - 进程间通信隔离

  • 隔离 System V IPC 和 POSIX 消息队列
  • 不同 IPC Namespace 中的进程无法通过共享内存,信号量,消息队列通信
  • 防止容器间通过 IPC 机制发生意外的数据泄露
# 查看当前 IPC 资源
ipcs

# 创建新的 IPC Namespace
sudo unshare --ipc bash
ipcs # 在新 Namespace 中看到的 IPC 资源是空的

User Namespace - 用户隔离

  • 隔离用户 ID 和组 ID
  • 关键特性:容器内的 root(UID 0)可以映射到宿主机的普通用户
  • 这是实现无特权容器(Rootless Container) 的基础
# 创建新的 User Namespace(无需 sudo!)
unshare --user --map-root-user bash
id # 输出: uid=0(root) gid=0(root)
# 看起来是 root,但实际上在宿主机上仍是普通用户

安全意义

User Namespace 是容器安全的重要防线.即使攻击者在容器内获取了 root 权限,由于 UID 映射,在宿主机上他只是一个普通用户,无法造成真正的破坏.这就是 Rootless Container 的核心原理.

Namespace 操作的三个系统调用

系统调用 作用 场景
clone() 创建新进程时同时创建新的 Namespace 容器运行时(runc)创建容器进程
unshare() 将当前进程移入新的 Namespace 命令行工具 unshare,调试
setns() 将当前进程加入已存在的 Namespace docker exec 进入容器,nsenter 命令
# 查看某个进程的 Namespace 信息
ls -la /proc/$$/ns/

# 输出示例:
# lrwxrwxrwx 1 root root 0 ... cgroup -> 'cgroup:[4026531835]'
# lrwxrwxrwx 1 root root 0 ... ipc -> 'ipc:[4026531839]'
# lrwxrwxrwx 1 root root 0 ... mnt -> 'mnt:[4026531841]'
# lrwxrwxrwx 1 root root 0 ... net -> 'net:[4026531840]'
# lrwxrwxrwx 1 root root 0 ... pid -> 'pid:[4026531836]'
# lrwxrwxrwx 1 root root 0 ... user -> 'user:[4026531837]'
# lrwxrwxrwx 1 root root 0 ... uts -> 'uts:[4026531838]'
#
# 方括号中的数字是 Namespace 的 inode 号
# 相同 inode 号 = 在同一个 Namespace

利用 /proc/<pid>/ns/ 下的文件,可以让其他进程通过 setns() 加入已有容器的 Namespace--这就是 docker execkubectl exec 的底层原理.

小结:Namespace 与容器的关系

┌─────────────────┐     ┌─────────────────┐
│ 容器 A │ │ 容器 B │
│ │ │ │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ PID NS │ │ │ │ PID NS │ │
│ ├───────────┤ │ │ ├───────────┤ │
│ │Network NS │ │ │ │Network NS │ │
│ ├───────────┤ │ │ ├───────────┤ │
│ │ Mount NS │ │ │ │ Mount NS │ │
│ └───────────┘ │ │ └───────────┘ │
└────────┬────────┘ └────────┬────────┘
│ │
└───────────┬───────────┘


┌────────────────────────┐
│ Linux Kernel │
│ (共享内核) │
└────────────────────────┘

关键区别:容器 vs 虚拟机

容器只是通过 Namespace 实现了资源视图的隔离,所有容器共享同一个内核.而虚拟机通过 Hypervisor 运行独立的 Guest OS 内核,隔离更彻底但开销更大.容器的优势在于启动快(毫秒级),资源占用小,密度高.

本篇要点回顾

一句话总结

容器 = 正常的进程 + 被限制的资源视图. 容器内运行的进程,占用的资源,在宿主机的进程树和资源树中都只是一个普通节点.但容器内的进程因为 Namespace 的存在,看不见全貌--它"以为"自己独占了一整台机器.

宿主机视角(完整进程树):

systemd(1)
├── sshd(800)
├── dockerd(900)
│ └── containerd(901)
│ ├── nginx(1200) ← 容器 A 的进程
│ └── redis(1300) ← 容器 B 的进程
└── cron(500)

容器 A 视角(PID Namespace 滤镜):

nginx(1) ← 同一个进程,PID 编号重新从 1 开始,看不到其他任何进程

要点回顾

要点 一句话概括
Namespace 本质 内核提供的资源视图隔离机制,让进程看到独立的系统资源
6 种核心类型 PID(进程)/ Network(网络)/ Mount(文件系统)/ UTS(主机名)/ IPC(通信)/ User(用户)
三个系统调用 clone(创建时隔离)/ unshare(当前进程进新NS)/ setns(加入已有NS)
容器的本质 容器 ≠ 虚拟机,容器只是戴了滤镜的普通进程,宿主机上一览无余
安全意义 User Namespace 实现 Rootless Container,容器内 root ≠ 宿主机 root

动手练习

  1. unshare --pid --fork --mount-proc bash 创建 PID Namespace,运行 ps aux 观察效果
  2. ip netns 创建两个 Network Namespace,配置 veth pair 并验证 ping 连通
  3. unshare --uts bash 修改主机名,退出后确认宿主机不受影响
  4. 运行一个 Docker 容器,用 ls -la /proc/<容器PID>/ns/ 查看其 Namespace inode,与宿主机对比
  5. 思考题:为什么 docker exec 能"进入"一个已运行的容器?底层调用了哪个系统调用?