最近深深的感觉到:吾生也有涯,而知也无涯。以有涯随无涯,殆已!前文看了kubelet调度过程,又对设置命名空间产生了好奇😂。于是记录一下。
如果你在服务器上运行了两个程序,你一定希望它们之间互不干扰——一个程序不应该看到另一个程序的文件,不应该抢占全部内存导致对方被 OOM 杀死。在没有容器技术的年代,解决这个问题的方法是虚拟机:每个虚拟机运行一个完整的操作系统内核,天然隔离。但虚拟机太重了——一个 CentOS 镜像动辄几个 GB,启动需要几十秒,只为跑一个 Python 服务。
容器的理想是:共享一个内核,但让每个进程以为自己独占整个系统。要做到这一点,需要解决两个问题:进程之间互相看不见(隔离),进程不能无限制地占用资源(限制)。Linux 内核分别用 namespace 和 cgroup 这两组机制来回答这两个问题。
2002 年,Linux 2.4.19 引入了第一个 namespace——mount namespace。当时的动机来自 VServer 项目和 OpenVZ 等早期的容器化尝试:如果要让多个用户共享同一台服务器,最基本的需求是每个用户看到的文件系统视图不同。mount namespace 由此诞生,让一组进程拥有独立的挂载点列表,看不到其他 namespace 中的挂载。
此后十年间,内核逐步将"进程的系统视图"拆分成多个独立的维度,每个维度对应一个 namespace 类型。2006 年 2.6.19 加入 UTS namespace(主机名隔离),2008 年 2.6.24 加入 IPC namespace(消息队列隔离),2008 年 2.6.29 加入 PID namespace(进程号隔离)和 network namespace(网络栈隔离),2013 年 3.8 加入 user namespace(用户 ID隔离),2016 年 4.6 加入 cgroup namespace。到此时,容器的隔离能力基本完备。
namespace 的核心思想是"视图隔离"——不复制资源,而是限制进程能看到哪些资源。内核为每类系统资源维护一个全局的数据结构(比如所有网络接口的链表、所有进程的 task_struct 链表),然后给每个 namespace 分配一个独立的"视图指针"。进程通过这个指针访问资源时,只能看到属于自己 namespace 的子集。
从进程的视角看,namespace 是透明的。一个运行在 PID namespace 中的进程调用 getpid() 返回 1,它不知道宿主机上自己的真实 PID 是 32768。一个运行在 network namespace 中的进程执行 ip addr 只看到 eth0 和 lo,它不知道宿主机上还有 ens33、docker0 等几十个接口。进程不需要修改代码就能在 namespace 中运行——系统调用接口不变,只是返回的结果被过滤了。
内核通过一个叫做 nsproxy 的结构体将进程与它的所有 namespace 关联起来:
// include/linux/nsproxy.h
struct nsproxy {
struct uts_namespace *uts_ns; // 主机名、域名
struct ipc_namespace *ipc_ns; // System V IPC、POSIX 消息队列
struct mnt_namespace *mnt_ns; // 挂载点视图
struct pid_namespace *pid_ns_for_children; // 进程 ID 空间
struct net *net_ns; // 网络栈
struct cgroup_namespace *cgroup_ns; // cgroup 路径视图
};
// 每个进程的 task_struct 中有一个 nsproxy 指针
// task_struct->nsproxy->net_ns 决定了进程看到哪个网络栈
内核提供了三种进入 namespace 的途径,它们的区别在于"创建新 namespace"还是"加入已有 namespace":
clone() — 创建新进程并同时创建新 namespace
#define _GNU_SOURCE
#include <sched.h>
int main() {
char stack[65536];
// CLONE_NEWNET | CLONE_NEWPID 创建新的 network 和 PID namespace
// 子进程从新 PID namespace 的角度看到自己是 PID 1
// 子进程的网络栈是全新的:只有 lo(DOWN 状态),无 IP
int pid = clone(child_func, stack + sizeof(stack),
CLONE_NEWNET | CLONE_NEWPID | SIGCHLD, NULL);
waitpid(pid, NULL, 0);
}
clone 是 fork 的增强版,通过 flags 控制父子进程之间共享哪些资源。CLONE_NEWNET 等标志告诉内核:不要让子进程继承父进程的 namespace,而是创建一个全新的。
unshare() — 在当前进程上创建新 namespace
#define _GNU_SOURCE
#include <sched.h>
int main() {
// 当前进程移入新的 mount namespace
// 不创建新进程,直接修改当前进程的 nsproxy
// 之后此进程的挂载操作对其他进程不可见
if (unshare(CLONE_NEWNS) < 0) {
perror("unshare failed");
return 1;
}
// 此后的 mount() 调用只影响新 mount namespace
mount("tmpfs", "/tmp", "tmpfs", 0, NULL);
}
unshare 和 clone 的区别是:clone 在创建子进程时设置 namespace,unshare 在当前进程上修改 namespace。unshare 不创建新进程,适用于"我想改变自己的运行环境"的场景。
setns() — 加入一个已存在的 namespace
#define _GNU_SOURCE
#include <sched.h>
#include <fcntl.h>
int main() {
// 打开目标进程的 network namespace
int fd = open("/proc/12345/ns/net", O_RDONLY);
// 将当前进程加入该 network namespace
// 此后当前进程的网络操作作用于目标进程的网络栈
setns(fd, CLONE_NEWNET);
close(fd);
// 现在执行 ip addr 看到的是 PID 12345 的网络接口
execlp("ip", "ip", "addr", NULL);
}
setns 是共享 namespace 的唯一方式。Kubernetes Pod 中多个容器共享网络栈,就是通过 setns 让业务容器加入 pause 容器的 network namespace 实现的。
PID namespace 隔离进程 ID 空间。每个 PID namespace 内的进程编号从 1 开始独立计数。父 namespace 可以看到子 namespace 中的进程(通过更大的全局 PID),但子 namespace 看不到父 namespace 的进程。PID 1 在每个 namespace 中具有特殊地位——它是 init 进程,负责回收僵尸进程,且 PID 1 退出会导致整个 namespace 内的进程被 SIGKILL。
Network namespace 隔离网络栈的完整视图,包括网络接口、IP 地址、路由表、iptables 规则、端口号空间、ARP 表、/proc/net 内容。新创建的 network namespace 只有一个状态为 DOWN 的 lo 接口,没有任何 IP 地址和路由。需要通过 CNI 插件配置 veth pair、分配 IP、设置路由后才能通信。
Mount namespace 隔离文件系统挂载点视图。不同 mount namespace 中的进程看到的挂载列表不同。容器通过 mount namespace 实现 rootfs 隔离——每个容器有自己的根文件系统(由镜像层叠加而成),看不到宿主机或其他容器的文件。
IPC namespace 隔离 System V IPC 对象(信号量、共享内存、消息队列)和 POSIX 消息队列。不同 IPC namespace 中的进程无法通过 shmget 等系统调用访问彼此的 IPC 对象。
UTS namespace 隔离 hostname 和 domain name。两个 UTS namespace 中的进程可以有不同的主机名,互不影响。
User namespace 隔离用户和组 ID。在 user namespace 内部,非特权用户可以映射为 root(UID 0),拥有该 namespace 内的特权,但在宿主机上仍然是普通用户。这是实现"非特权容器"的基础。
Cgroup namespace 隔离 cgroup 路径视图。进程看到的 /proc/self/cgroup 路径是相对于自己 namespace 根的路径,而不是宿主机上的绝对路径。
Kubernetes 的 Pod 模型基于 namespace 设计了一套共享策略:同一个 Pod 内的容器共享某些 namespace,不同 Pod 之间完全隔离。
Pod 创建时,kubelet 首先通过 CRI 让容器运行时创建一个 pause 容器(也叫 sandbox 容器)。pause 容器通过 clone() 创建新的 network、IPC、UTS namespace(这些 namespace 没有路径,由 clone 新建)。pause 容器的 PID 1 进程通过 pause() 系统调用永久睡眠,持有这些 namespace 的引用计数,防止内核回收。
随后 kubelet 调用 CNI 插件进入 pause 容器的 network namespace,创建 veth pair、分配 Pod IP、设置路由。
最后 kubelet 创建业务容器。业务容器的 OCI spec 中 network、IPC、UTS namespace 带有指向 pause 容器的路径:
{
"linux": {
"namespaces": [
{"type": "mount"},
{"type": "pid"},
{"type": "ipc", "path": "/proc/12345/ns/ipc"},
{"type": "uts", "path": "/proc/12345/ns/uts"},
{"type": "network", "path": "/proc/12345/ns/net"}
]
}
}
runc 看到 network namespace 有 path,不调用 clone(CLONE_NEWNET) 创建新 netns,而是调用 setns() 加入 pause 容器的 netns。这样业务容器和 pause 容器共享同一个网络栈——同一个 eth0、同一个 IP、同一个路由表,可以通过 localhost 互相通信。
mount 和 PID namespace 不带 path,runc 通过 clone(CLONE_NEWNS | CLONE_NEWPID) 为每个业务容器新建独立的 mount 和 PID namespace。这意味着 Pod 内容器有各自独立的文件系统视图和进程空间,不能直接看到对方的文件和进程。
这套设计模拟了"一台虚拟机上跑多个紧密协作的进程"的场景:共享网络和 IPC 便于容器间高性能通信,独立的 mount 和 PID 保证文件系统和进程的隔离。
namespace 解决了"看不见"的问题,但没解决"抢不到"的问题。两个容器即使完全隔离了视图,如果都在同一个内核上运行,一个容器跑满了 CPU,另一个容器就只能等待;一个容器占满了内存,OOM killer 可能杀掉另一个容器的进程。
2006 年,Google 工程师 Paul Menage 和 Rohit Seth 发起了"Process Containers"项目,目标是提供一种通用的进程分组和资源限制机制。2007 年合入 Linux 2.6.24 主线时改名为 cgroup(control group)。同期另一个相关项目是 namespace,两者最初都属于 "Containers" 这个大特性,后来被拆分成独立的子系统。
cgroup 经历了两代演进。cgroup v1 为每种资源类型(CPU、内存、IO 等)维护独立的层级树,进程通过在不同树中分别创建子组来接受不同维度的限制。v1 的问题是各资源控制器之间缺乏协调,层级管理复杂且语义不一致。2016 年 Linux 4.5 引入 cgroup v2,统一为单一层级树,所有资源控制器挂在同一棵树上,进程在一个 cgroup 目录中接受所有维度的限制。Kubernetes 从 1.25 开始默认使用 cgroup v2。
cgroup 的核心思想是"资源分组 + 策略控制"。内核将一组进程关联到一个 cgroup 目录,然后在该目录下写入控制参数来限制这组进程能使用的资源量。
cgroup 的组织结构是一棵树。根节点代表整个系统,每个子目录是一个 cgroup,包含一组进程和一组资源控制参数。子 cgroup 的资源限制不能超过父 cgroup 的限制。进程可以从一个 cgroup 迁移到另一个(通过写 cgroup.procs 文件),但不能同时属于同一层级中的多个 cgroup。
cgroup v2 的目录结构示例:
/sys/fs/cgroup/ ← cgroup v2 根挂载点
├── cgroup.controllers ← 可用的控制器: cpu memory io pids ...
├── cgroup.subtree_control ← 启用子级控制器
├── cpu.max ← 根级 CPU 限制
├── memory.max ← 根级内存限制
│
├── kubepods/ ← kubelet 创建的 cgroup 根
│ ├── besteffort/ ← BestEffort Pod 的 cgroup
│ │ ├── pod12345.../
│ │ │ ├── cgroup.procs
│ │ │ ├── cpu.max ← 50000 100000 (50% CPU)
│ │ │ └── memory.max ← 536870912 (512MB)
│ │ └── pod67890.../
│ │
│ ├── burstable/ ← Burstable Pod 的 cgroup
│ │ └── podabcde.../
│ │ ├── cpu.max ← 200000 100000 (2 核)
│ │ └── memory.max ← 2147483648 (2GB)
│ │
│ └── guaranteed/ ← Guaranteed Pod 的 cgroup
│ └── podfghij.../
│ ├── cpu.max ← 400000 100000 (4 核)
│ └── memory.max ← 4294967296 (4GB)
每个 cgroup 目录中有两类文件:cgroup.procs 列出属于该组的进程 PID,cpu.max / memory.max 等文件是资源控制参数。往 cpu.max 写入 50000 100000 表示在每 100000 微秒的调度周期内,这组进程最多使用 50000 微秒 CPU 时间(即 50%)。
CPU 控制器(cpu)
CPU 控制器通过 CFS(Completely Fair Scheduler)带宽控制实现限速。核心参数是 cpu.max,格式为 $MAX $PERIOD,表示在每 $PERIOD 微秒内最多使用 $MAX 微秒 CPU 时间。
# 限制该 cgroup 中的进程最多使用 1.5 核 CPU
# 150000 微秒 / 100000 微秒 = 1.5 核
echo "150000 100000" > /sys/fs/cgroup/myapp/cpu.max
内核 CFS 带宽控制的实现(简化):
// kernel/sched/fair.c
// 每个 cgroup 对应一个 task_group,其中有一个 CFS 带额度的运行队列
struct cfs_bandwidth {
u64 quota; // cpu.max 中的 $MAX(允许的 CPU 时间)
u64 period; // cpu.max 中的 $PERIOD(统计周期)
u64 runtime; // 当前周期内剩余的 CPU 时间
// ...
};
// 每次 CFS 调度时钟 tick 时检查
static void sched_cfs_period_timer(struct cfs_bandwidth *cfs_b) {
// 周期结束,重置 runtime
cfs_b->runtime = cfs_b->quota;
}
// 进程准备运行时检查配额
static bool cfs_bandwidth_used(struct cfs_bandwidth *cfs_b, u64 delta) {
if (cfs_b->runtime >= delta) {
cfs_b->runtime -= delta; // 扣减配额
return true; // 允许运行
}
return false; // 配额用完,进程被节流(throttle)
// 被节流的进程放入等待队列,下一个周期重置 runtime 后才能继续运行
}
如果进程在当前周期内用完了 quota,CFS 会将其"节流"(throttle)——从运行队列移除,放入等待队列,直到下一个 period 开始时 runtime 重置后才重新加入运行队列。从外部看,进程的 CPU 使用率被限制在 quota / period 的水平。
CPU 份额(cpu.weight)
除了硬性限制,cgroup v2 还提供 cpu.weight(v1 中是 cpu.shares),用于在 CPU 资源紧张时按比例分配。这是一个相对权重,不是绝对限制:
# cgroup A 权重 512,cgroup B 权重 1024
# 当 CPU 紧张时,A 得到 512/(512+1024) = 1/3 的 CPU
# 当 CPU 空闲时,A 可以用到全部 CPU
echo 512 > /sys/fs/cgroup/appA/cpu.weight
echo 1024 > /sys/fs/cgroup/appB/cpu.weight
内存控制器(memory)
内存控制器限制进程组的内存使用量,核心参数是 memory.max:
# 限制该 cgroup 最多使用 2GB 内存
echo 2147483648 > /sys/fs/cgroup/myapp/memory.max
内核为每个 cgroup 维护一个内存计数器。进程每次分配内存页时,内核将该页计入其 cgroup 的内存统计;释放时扣减。当统计值超过 memory.max 时,内核根据 memory.oom.group 的设置决定行为——如果设为 1,直接杀死整个 cgroup 中的所有进程;如果为 0(默认),由内核 OOM killer 选择性地杀进程。
// mm/memcontrol.c(简化)
static int try_charge_memcg(struct mem_cgroup *memcg, gfp_t gfp_mask,
unsigned int nr_pages)
{
// 尝试增加 memcg 的内存使用计数
if (memcg->memory.current + nr_pages > memcg->memory.max) {
// 超过限制
if (memcg->memory.oom.group) {
// 杀死整个 cgroup 的所有进程
memory_oom_kill(memcg);
} else {
// 尝试回收内存(触发直接内存回收)
if (!try_to_free_mem_cgroup_pages(memcg, nr_pages, gfp_mask))
return -ENOMEM; // 回收失败,返回 OOM
}
}
memcg->memory.current += nr_pages;
return 0;
}
内存控制器还提供 memory.high(软限制,超过时触发异步回收但不杀进程)、memory.swap.max(限制 swap 使用量)、memory.current(当前使用量,只读)等参数。
PID 控制器(pids)
PID 控制器限制 cgroup 中能创建的进程/线程数量,防止 fork bomb:
# 限制该 cgroup 最多 500 个进程
echo 500 > /sys/fs/cgroup/myapp/pids.max
// kernel/cgroup/pids.c(简化)
static int pids_can_fork(struct cgroup_subsys_state *css, struct task_struct *task)
{
struct pids_cgroup *pids = css_to_pids(css);
if (pids->limit == PIDS_MAX) // 无限制
return 0;
// 原子递增当前计数
int current = atomic_inc_return(&pids->counter);
if (current > pids->limit) {
atomic_dec(&pids->counter); // 回滚
return -EAGAIN; // fork 失败
}
return 0;
}
// fork 系统调用路径中会调用此回调函数
// 如果返回非 0,fork 被拒绝
IO 控制器(io)
IO 控制器限制块设备的读写带宽和 IOPS:
# 限制对 /dev/sda 的写入速率为 100MB/s
# 格式: $MAJOR:$MINOR rbps=$BYTES wbps=$BYTES riops=$IOPS wiops=$IOPS
echo "8:0 wbps=104857600" > /sys/fs/cgroup/myapp/io.max
QoS 等级与 cgroup 层级
Kubernetes 根据 Pod 的 resources.requests 和 resources.limits 将 Pod 分为三个 QoS 等级,每个等级对应不同的 cgroup 配置策略:
Guaranteed 等级要求每个容器的 CPU 和内存的 request 等于 limit。kubelet 将这类 Pod 放在 kubepods/guaranteed/ cgroup 下,cpu.max 设置为 limit 值,memory.max 设置为 limit 值。由于 request 等于 limit,这类 Pod 的资源得到完全保障,不会被驱逐(除非节点整体 OOM)。
Burstable 等级要求至少一个容器指定了 request(但 request 不等于 limit)。kubelet 将这类 Pod 放在 kubepods/burstable/ cgroup 下,cpu.weight 根据 request 按比例设置(不是硬限制),memory.max 设置为 limit 值。CPU 资源紧张时按 request 比例分配,内存超过 limit 时容器被 OOM 杀死。
BestEffort 等级是所有容器都没有指定 request 和 limit 的 Pod。kubelet 将这类 Pod 放在 kubepods/besteffort/ cgroup 下,不设置 cpu.max 和 memory.max(使用默认值,即无限制)。但在节点资源紧张时,BestEffort Pod 最先被驱逐。
// pkg/kubelet/cm/internal_container_lifecycle_linux.go(简化)
// kubelet 为每个容器配置 cgroup 参数
func (i *internalContainerLifecycle) CreateContainer(
pod *v1.Pod, container *v1.Container, containerID string) error {
// 构建 cgroup 配置
lc := &CgroupConfig{
Name: containerCgroupName(pod, container),
ResourceParameters: &ResourceConfig{
MemoryMax: container.Resources.Limits.Memory().Value(),
MemoryMin: container.Resources.Requests.Memory().Value(),
CPUQuota: cpuQuota(container), // cpu.max
CPUShares: cpuShares(container), // cpu.weight
PidsLimit: containerResources.PidsLimit,
},
}
// 写入 cgroup 文件系统
return i.cgroupManager.Create(lc)
}
// cpu.max 的计算
func cpuQuota(container *v1.Container) int64 {
// limits.cpu = 2.5 核
// quota = 250000 微秒, period = 100000 微秒
cpuLimit := container.Resources.Limits.Cpu().MilliValue() // 2500
if cpuLimit == 0 {
return -1 // 不限制
}
quota := cpuLimit * 100 // 250000 微秒
period := int64(100000) // 100ms 标准周期
return quota / (period / 100000)
}
CPU Manager 与 NUMA 亲和性
对于 Guaranteed Pod,kubelet 的 CPU Manager 可以将 CPU 核心独占分配给容器。当 cpuManagerPolicy=static 时,kubelet 通过 cgroup 的 cpuset 控制器将整数核 CPU 绑定到容器,容器中的进程只在这些核上运行,不被其他进程打扰:
# 容器请求 2 个完整 CPU 核
# kubelet 通过 cpuset.cpus 绑定到核 4 和核 5
echo "4-5" > /sys/fs/cgroup/.../cpuset.cpus
OOM 行为与节点驱逐
当节点内存紧张时,内核 OOM killer 根据 cgroup 的 oom_score 来选择杀死哪个进程。kubelet 通过设置 memory.oom.group=1 确保 OOM 时杀死整个容器(而非单个进程)。同时 kubelet 自身的 eviction 机制会监控节点级别的内存、磁盘压力,在内核 OOM 之前主动驱逐低优先级 Pod(先 BestEffort,再 Burstable)。
cgroup v2 与 systemd cgroup driver
kubelet 需要通过 cgroup driver 管理容器的 cgroup。有两种 driver:cgroupfs(直接写 /sys/fs/cgroup/ 下的文件)和 systemd(通过 systemd 的 D-Bus API 创建 cgroup slice)。容器运行时也需要与 kubelet 使用相同的 cgroup driver,否则会出现 cgroup 路径不一致导致资源限制失效的问题。推荐使用 systemd driver,因为它与操作系统的资源管理更好地集成。
namespace 和 cgroup 分别从两个维度实现容器隔离,缺一不可。namespace 负责"你能看到什么"——进程能访问哪些 PID、哪些网络接口、哪些文件系统挂载点。cgroup 负责"你能用多少"——进程能用多少 CPU、多少内存、多少 IO 带带、能创建多少进程。
两者的结合点在于:cgroup 通过 cgroup.procs 文件关联进程,而进程的 task_struct 中同时持有 nsproxy(namespace 指针)和 cgroup 指针。内核在进程的每次系统调用中,根据 nsproxy 过滤资源可见性,根据 cgroup 限制资源使用量。
一个容器完整的创建过程,就是同时设置这两组约束的过程。runc 在 nsexec 阶段通过 clone 和 setns 设置 namespace,在 standard_init_linux.go 的 Init() 阶段由父进程通过写 cgroup 文件设置资源限制,最后通过 execve 启动业务进程。业务进程从第一条指令开始就同时处于正确的 namespace 集合和 cgroup 限制中。
// 进程的 task_struct 中同时持有这两组约束
struct task_struct {
struct nsproxy *nsproxy; // namespace 约束(可见性)
struct css_set __rcu *cgroups; // cgroup 约束(资源限制)
// ...
};
// 每次系统调用时:
// - open()/stat() → 通过 nsproxy->mnt_ns 过滤挂载点视图
// - socket()/bind() → 通过 nsproxy->net_ns 路由到正确的网络栈
// - fork() → 检查 cgroups->pids 限制是否允许创建新进程
// - malloc()/brk() → 检查 cgroups->memory 限制是否允许分配内存
// - 调度器 tick → 检查 cgroups->cpu 带宽是否已耗尽
namespace 和 cgroup 是 Linux 容器的两大基石。namespace 通过视图隔离让进程以为自己是系统上唯一的用户,cgroup 通过资源限制让进程不能无限消耗系统资源。两者都是内核层面的机制,对用户态进程透明——进程不需要修改代码就能被放进容器中运行。
Kubernetes 在这两个机制之上构建了 Pod 模型和 QoS 体系。Pod 通过 pause 容器持有共享的 network/IPC/UTS namespace,业务容器通过 setns 加入,实现容器间的高效通信。同时每个容器在自己的 cgroup 中接受 CPU、内存、PID 等维度的资源限制,kubelet 根据 QoS 等级为不同优先级的 Pod 分配不同的资源保障策略。
理解 namespace 和 cgroup 的工作原理,是理解容器行为的基础——无论是排查"容器为什么 OOM"、"Pod 内容器为什么能通过 localhost 通信"、"CPU limit 为什么不起作用"等实际问题,还是评估容器安全边界,都需要回到这两个内核机制上来。
读完全文,提出一个问题:网络命名空间隔离,到底隔离了什么呢?