第 16 章

容器底层

第16章:容器底层原理

Docker 和 Kubernetes 的底层并没有魔法:容器本质上是一组 Linux 内核特性的组合。本章拆解六种 namespace(进程/网络/文件系统/主机名/IPC/用户)、cgroup v2 资源隔离、overlay 文件系统的分层设计、chroot/pivot_root 根文件系统切换、seccomp 系统调用过滤,最后用一个完整的 Shell 脚本手动"制造"一个容器,彻底理解 runc/containerd 的工作原理。

1. 为什么需要容器隔离

在同一台主机上直接运行多个服务存在根本性风险:任何进程可以看到其他所有进程(ps aux)、共享同一网络栈(端口冲突、流量嗅探)、访问相同文件系统(配置文件、密钥泄露)。内核通过以下机制实现隔离:

隔离需求 Linux 机制 作用
进程可见性 PID namespace 容器内只能看到自己的进程树
网络栈 Network namespace 独立网卡、IP、路由、iptables
文件系统视图 Mount namespace + pivot_root 容器有独立的根文件系统
主机名 UTS namespace 容器可以有自己的 hostname
进程间通信 IPC namespace 隔离 System V IPC 和消息队列
用户权限 User namespace 非特权用户映射为容器内 root
CPU/内存/IO cgroup v2 硬性限制资源用量,防止争抢
系统调用 seccomp 只允许白名单中的系统调用

2. Linux Namespace 六种类型

PID Namespace — 进程隔离

PID namespace 创建一个独立的进程 ID 空间。在新 namespace 中,第一个进程的 PID 为 1(相当于容器内的 init/systemd),但在宿主机上它有另一个真实 PID。子进程看不到父 namespace 的进程,但父 namespace 可以看到所有子进程。

# 在新 PID namespace 中运行 bash(需要 root 或 user namespace)
unshare --pid --fork --mount-proc bash

# 在新 shell 内查看进程
ps aux
# 只能看到当前 bash 和 ps 两个进程
# PID 1 = bash(新 namespace 的 init)

# 在宿主机上查看对应的真实 PID
# 新 namespace 中 PID 1 在宿主机上可能是 PID 12345

# /proc 文件系统与 PID namespace
# 每个 PID namespace 需要有自己挂载的 /proc
mount -t proc proc /proc    # 挂载新 namespace 的 /proc

# 查看进程的所有 namespace
ls -la /proc/1/ns/
# lrwxrwxrwx 1 root root 0  pid -> pid:[4026531836]
# lrwxrwxrwx 1 root root 0  net -> net:[4026531992]
# ...
# 相同数字 = 相同 namespace,不同数字 = 已隔离

# 查看某进程所属的 namespace ID
readlink /proc/$(pgrep nginx)/ns/pid

Network Namespace — 网络栈隔离

# ip netns — 管理命名 network namespace
ip netns add mycontainer          # 创建命名 netns
ip netns list                     # 列出所有命名 netns
ip netns exec mycontainer ip a    # 在指定 netns 中执行命令
ip netns delete mycontainer       # 删除

# 在新 netns 中只有 loopback,无法与外界通信
ip netns exec mycontainer ip link show
# 1: lo:  mtu 65536 ...(默认 DOWN)

# veth pair:连通两个 namespace 的"虚拟网线"
# 创建 veth pair(veth0 留在主机,veth1 移入容器 netns)
ip link add veth0 type veth peer name veth1
ip link set veth1 netns mycontainer

# 配置 IP
ip addr add 10.0.0.1/24 dev veth0
ip link set veth0 up

ip netns exec mycontainer ip addr add 10.0.0.2/24 dev veth1
ip netns exec mycontainer ip link set veth1 up
ip netns exec mycontainer ip link set lo up

# 测试连通性
ping -c 2 10.0.0.2
ip netns exec mycontainer ping -c 2 10.0.0.1

# 为容器配置 NAT(让容器能访问外网)
iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
ip netns exec mycontainer ip route add default via 10.0.0.1

# 清理
ip netns delete mycontainer

Mount / UTS / IPC Namespace

# Mount namespace:隔离文件系统挂载点
# 在新 mount namespace 中挂载不影响宿主机
unshare --mount bash
mount --bind /tmp/myroot /mnt/test
# 在宿主机上 /mnt/test 不会出现这个挂载

# UTS namespace:独立 hostname 和 domainname
unshare --uts bash
hostname mycontainer          # 修改不影响宿主机
hostname                      # → mycontainer
# 在另一个终端查看宿主机 hostname,仍然是原值

# IPC namespace:隔离 System V IPC(共享内存/信号量/消息队列)
unshare --ipc bash
ipcs                          # 查看当前 IPC 资源(新 namespace 内为空)
# System V 消息队列、共享内存段、信号量在不同 namespace 间完全隔离

# 查看一个进程所在的所有 namespace
# 两个进程如果 namespace 文件指向同一个 inode,表示它们共享该 namespace
stat /proc/self/ns/uts
stat /proc/1/ns/uts

User Namespace — 用户权限映射

# User namespace 允许非 root 用户在容器内拥有 root 权限
# 原理:将容器内的 UID/GID 映射到宿主机的普通用户

# 作为普通用户(uid=1000)创建 user namespace
unshare --user --map-root-user bash
whoami    # → root(在新 namespace 中)
id        # → uid=0(root) gid=0(root) groups=0(root)
# 但宿主机上这个进程实际是 uid=1000

# UID/GID 映射配置文件
cat /proc/self/uid_map
# 格式:容器UID  宿主机UID  范围
# 0         1000      1
# 表示:容器内的 uid 0 映射到宿主机的 uid 1000,范围1个

# 允许更大范围的 UID 映射(需配置 /etc/subuid /etc/subgid)
cat /etc/subuid
# alice:100000:65536   # alice 可以使用 100000-165535 作为子 UID

# 使用 newuidmap/newgidmap 设置完整映射
newuidmap PID 0 1000 1 1 100000 65536
# 容器UID 0 → 宿主机UID 1000
# 容器UID 1 → 宿主机UID 100000(共65536个)

# rootless 容器就是利用 user namespace 实现的
# podman 默认使用 rootless 模式

3. unshare 实战:创建完整隔离环境

# 创建一个具有完整隔离的 shell 环境(类容器)
# --fork: 在子进程中运行(PID namespace 的第一个进程作为 PID 1)
# --pid: 新 PID namespace
# --net: 新 network namespace
# --mount: 新 mount namespace
# --uts: 新 UTS namespace
# --ipc: 新 IPC namespace
# --user --map-root-user: 新 user namespace,当前用户映射为 root

unshare \
  --pid \
  --fork \
  --net \
  --mount \
  --uts \
  --ipc \
  --user \
  --map-root-user \
  /bin/bash

# 进入后配置隔离环境
hostname isolated-container       # 设置容器 hostname
mount -t proc proc /proc          # 挂载 proc(使 ps 正常工作)

# 验证隔离效果
hostname                           # → isolated-container(与宿主机不同)
ps aux                             # → 只看到容器内进程
ip a                               # → 只有 lo(无宿主机网卡)
ls /proc/1/ns/                     # → 查看 namespace inode,与宿主机不同

# 在宿主机上查看该进程
# ps aux | grep bash
# 在宿主机看到真实 PID 和 UID

4. nsenter:进入运行中容器的 Namespace

nsenter 允许在不重启进程的情况下,进入一个运行中进程的任意 namespace 执行命令。这是调试容器网络、文件系统问题的核心工具,也是 docker exec 的底层实现原理。

# 找到容器进程的 PID(宿主机视角)
# 假设容器内 PID 1 在宿主机上是 PID 12345
CPID=12345

# 进入容器的所有 namespace(相当于 docker exec -it shell)
nsenter -t $CPID --pid --net --mount --uts --ipc /bin/bash

# 只进入 network namespace(调试网络)
nsenter -t $CPID --net ip a
nsenter -t $CPID --net ss -tlnp
nsenter -t $CPID --net ping 8.8.8.8

# 只进入 mount namespace(检查文件系统)
nsenter -t $CPID --mount ls /
nsenter -t $CPID --mount cat /etc/resolv.conf

# 实用场景:容器没有调试工具(busybox/distroless 镜像)
# 从宿主机进入容器 network namespace,使用宿主机的 tcpdump 抓包
nsenter -t $CPID --net -- tcpdump -i eth0 -w /tmp/cap.pcap

# 用 docker 获取容器 PID
CPID=$(docker inspect --format '{{"{{"}}{{".State.Pid"}}{{"}}"}}' mycontainer)
nsenter -t $CPID --net ip route show

# 进入时保留宿主机的 PATH(避免 PATH 被覆盖)
nsenter -t $CPID --pid --net --mount -- env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin /bin/bash

5. cgroup v2:资源隔离与限制

cgroup(control group)是 Linux 内核的资源管理机制,允许对一组进程施加硬性的 CPU、内存、I/O、进程数限制。cgroup v2 是 v1 的重新设计,统一了层次结构,Linux 5.2+ 和现代发行版默认使用 v2。

# 检查是否使用 cgroup v2
mount | grep cgroup
# cgroup2 on /sys/fs/cgroup type cgroup2 ...  ← v2
# cgroup on /sys/fs/cgroup/cpu type cgroup ...  ← v1(多个挂载点)

# cgroup v2 根目录
ls /sys/fs/cgroup/
# cgroup.controllers  cgroup.subtree_control  system.slice  user.slice ...

# 查看当前进程所属的 cgroup
cat /proc/self/cgroup
# 0::/user.slice/user-1000.slice/session-1.scope

# 查看可用控制器
cat /sys/fs/cgroup/cgroup.controllers
# cpuset cpu io memory hugetlb pids rdma misc

# === 创建并使用 cgroup ===

# 创建新 cgroup(在根 cgroup 下创建子目录即可)
mkdir /sys/fs/cgroup/mycontainer

# 启用控制器(在父 cgroup 的 subtree_control 文件中声明)
echo "+cpu +memory +pids +io" > /sys/fs/cgroup/cgroup.subtree_control

# 设置 CPU 配额(cpu.max 格式:配额 周期,单位 微秒)
# 50000/100000 = 50% CPU(一个周期内最多使用 50ms)
echo "50000 100000" > /sys/fs/cgroup/mycontainer/cpu.max

# 设置内存上限(字节)
echo $((512 * 1024 * 1024)) > /sys/fs/cgroup/mycontainer/memory.max   # 512MiB
echo $((512 * 1024 * 1024)) > /sys/fs/cgroup/mycontainer/memory.swap.max  # 禁用 swap

# 设置最大进程数
echo 100 > /sys/fs/cgroup/mycontainer/pids.max

# 设置 I/O 限速(io.max 格式:MAJOR:MINOR rbps=X wbps=X riops=X wiops=X)
# 先查设备号:ls -la /dev/sda → 8, 0
echo "8:0 rbps=10485760 wbps=10485760" > /sys/fs/cgroup/mycontainer/io.max  # 10MB/s

# 将进程加入 cgroup(写入 PID 到 cgroup.procs)
echo $$ > /sys/fs/cgroup/mycontainer/cgroup.procs   # 将当前 shell 加入

# 验证:列出 cgroup 中的进程
cat /sys/fs/cgroup/mycontainer/cgroup.procs

# 查看实时统计
cat /sys/fs/cgroup/mycontainer/cpu.stat
cat /sys/fs/cgroup/mycontainer/memory.current
cat /sys/fs/cgroup/mycontainer/memory.stat

# 测试内存限制(会触发 OOM kill)
# stress --vm 1 --vm-bytes 600M    # 尝试分配 600M,超过限制

# systemd 与 cgroup 的关系
# 每个 service/scope 都有对应的 cgroup
systemctl status nginx
# 显示:CGroup: /system.slice/nginx.service/...
# 可以通过 systemd 直接设置资源限制
systemctl set-property nginx.service CPUQuota=50% MemoryMax=512M

6. chroot 与 pivot_root:根文件系统切换

chroot — 简单隔离与其局限

# 创建一个最小 chroot 环境
ROOTFS=/tmp/minichroot
mkdir -p $ROOTFS/{bin,lib,lib64,dev,proc,sys,etc}

# 复制 bash 和必要的库
cp /bin/bash $ROOTFS/bin/
cp /bin/ls $ROOTFS/bin/
cp /bin/ps $ROOTFS/bin/

# 复制动态库依赖
ldd /bin/bash
# linux-vdso.so.1 → 内核提供,不需复制
# libtinfo.so.6 → /lib/x86_64-linux-gnu/libtinfo.so.6
# libc.so.6 → /lib/x86_64-linux-gnu/libc.so.6

cp /lib/x86_64-linux-gnu/libtinfo.so.6 $ROOTFS/lib/
cp /lib/x86_64-linux-gnu/libc.so.6 $ROOTFS/lib/
cp /lib64/ld-linux-x86-64.so.2 $ROOTFS/lib64/

# 挂载必要的伪文件系统
mount -t proc proc $ROOTFS/proc
mount --bind /dev $ROOTFS/dev

# 进入 chroot 环境
chroot $ROOTFS /bin/bash
ls /                  # 看到的是 $ROOTFS 下的目录结构
ps aux                # 需要 /proc 挂载才能工作

# chroot 的安全问题:有 root 权限可以逃逸
# 逃逸原理:打开一个指向外部的文件描述符,然后 chroot 到子目录,
# 再通过 fchdir(fd) + chdir("..") 逐步爬出 chroot 笼
# 因此 chroot 不能单独用于安全隔离

# 清理
umount $ROOTFS/proc
umount $ROOTFS/dev

pivot_root — 安全的根文件系统切换

# pivot_root 是 runc 使用的正确方式:
# 1. 将新根挂载到某目录
# 2. 使用 pivot_root 将新根提升为真正的 /
# 3. 卸载旧根(彻底断开与宿主机文件系统的联系)

# 在 mount namespace 中操作(避免影响宿主机)
# 步骤演示(在隔离的 mount namespace 中)

NEWROOT=/tmp/newroot
mkdir -p $NEWROOT $NEWROOT/.oldroot

# 将新根文件系统绑定挂载(或使用 overlayfs)
mount --bind $NEWROOT $NEWROOT

# 切换根文件系统:
# pivot_root 新根  旧根的临时挂载点
cd $NEWROOT
pivot_root . .oldroot

# 现在 / 是 $NEWROOT
# .oldroot 是旧的宿主机根文件系统

# 挂载新 /proc
mount -t proc proc /proc

# 卸载旧根(彻底隔离)
umount -l /.oldroot
rmdir /.oldroot

# 现在完全无法访问宿主机文件系统
# 这就是为什么 pivot_root + mount namespace 比 chroot 安全得多

# runc 的 pivot_root 实现流程(简化):
# 1. unshare(CLONE_NEWNS)  # 新 mount namespace
# 2. 挂载 overlayfs 作为新根
# 3. pivot_root(newroot, newroot/.pivot)
# 4. umount /.pivot
# 5. 继续配置其他 namespace

7. Overlay 文件系统:镜像分层原理

Overlay(联合)文件系统将多个目录"叠加"成一个统一视图:下层(lowerdir)只读,上层(upperdir)可写,所有修改写入 upperdir,不影响 lowerdir。这正是 Docker 镜像分层的核心原理:每一个 RUN/COPY 指令生成一个只读层,容器启动时在最上层添加可写层。

# 创建 overlay 所需的目录结构
OVDIR=/tmp/overlay-demo
mkdir -p $OVDIR/{lower1,lower2,upper,work,merged}

# 在只读层写入基础文件
echo "from lower1" > $OVDIR/lower1/file1.txt
echo "from lower2" > $OVDIR/lower2/file2.txt
echo "original"   > $OVDIR/lower1/shared.txt

# 挂载 overlay 文件系统
# lowerdir: 冒号分隔,右边优先级更高(lower2 > lower1)
mount -t overlay overlay \
  -o lowerdir=$OVDIR/lower2:$OVDIR/lower1,\
upperdir=$OVDIR/upper,\
workdir=$OVDIR/work \
  $OVDIR/merged

# 验证联合视图
ls $OVDIR/merged
# file1.txt  file2.txt  shared.txt  (来自两个 lower 层)

cat $OVDIR/merged/file1.txt   # → from lower1
cat $OVDIR/merged/file2.txt   # → from lower2

# 在 merged 中修改文件(写时复制 CoW)
echo "modified" > $OVDIR/merged/shared.txt

# 修改后:upper 层出现了 shared.txt 的副本
cat $OVDIR/upper/shared.txt   # → modified
cat $OVDIR/lower1/shared.txt  # → original(原文件未被修改!)

# 在 merged 中删除文件(创建 whiteout 文件)
rm $OVDIR/merged/file1.txt
ls $OVDIR/upper/
# c--------- 1 root root 0, 0 ... file1.txt  ← whiteout 文件(设备号0,0)
# 表示"这个文件在此层被删除",lower 层的 file1.txt 还在但被遮盖

# Docker 使用 overlay2 驱动(内置支持,无需 FUSE)
# 查看 Docker 使用的 overlayfs
docker inspect mycontainer | grep -A5 GraphDriver

# Docker 镜像层的存储位置
ls /var/lib/docker/overlay2/
# 每个目录对应一个镜像层(diff/ merged/ link/ lower/ work/)

# 卸载
umount $OVDIR/merged

8. seccomp:系统调用过滤

seccomp(secure computing mode)允许进程声明只使用哪些系统调用,内核将拒绝其他所有调用。这减少了容器逃逸的攻击面:即使容器内代码被攻陷,攻击者也无法调用 ptracemountkexec_load 等危险 syscall。

# 检查内核是否支持 seccomp
grep CONFIG_SECCOMP /boot/config-$(uname -r)
# CONFIG_SECCOMP=y
# CONFIG_SECCOMP_FILTER=y

# 检查进程当前的 seccomp 状态
cat /proc/self/status | grep Seccomp
# Seccomp: 0    (0=未启用, 1=STRICT模式, 2=FILTER模式)

# SECCOMP_MODE_STRICT:只允许 read/write/exit/sigreturn 4个 syscall
# SECCOMP_MODE_FILTER:基于 BPF 规则的白名单/黑名单

# strace 测试程序实际使用的 syscall(生成白名单的第一步)
strace -c -f -e trace=all ./myapp 2>&1 | grep -E "^[0-9]" | awk '{print $NF}'

# 使用 libseccomp 工具查看 syscall 编号
scmp_sys_resolver openat
scmp_sys_resolver write

# Docker 默认 seccomp profile(位于 /etc/docker/seccomp.json)
# 约300个 syscall 被允许,危险的 syscall 被阻断:
# 阻断:kexec_load, create_module, init_module, delete_module
# 阻断:mount(需要 --privileged 或 SYS_ADMIN capability)
# 阻断:ptrace(防止进程注入)
# 阻断:clone with CLONE_NEWUSER(防止 user namespace 逃逸)

# 运行容器时指定自定义 seccomp profile
docker run --security-opt seccomp=/path/to/custom-profile.json myimage

# 禁用 seccomp(不推荐,仅调试用)
docker run --security-opt seccomp=unconfined myimage

# 用 bpftrace 观察被 seccomp 拒绝的 syscall
bpftrace -e 'tracepoint:syscalls:sys_enter_seccomp { printf("pid=%d comm=%s\n", pid, comm); }'

9. Linux Capabilities 在容器中

传统 Unix 权限模型只有"root(全能)"和"普通用户"两种。Linux capabilities 将 root 的超级权限拆分为约 40 种独立能力,进程只需要声明自己需要的 capability,降低被攻陷后的损害。

# 查看所有 capability
man capabilities | grep "CAP_" | head -30

# 常见 capabilities(容器相关):
# CAP_NET_BIND_SERVICE  绑定 1024 以下端口(nginx 需要)
# CAP_NET_ADMIN         配置网络接口、路由(容器网络设置需要)
# CAP_SYS_PTRACE        ptrace 其他进程(调试用,通常禁用)
# CAP_SYS_ADMIN         大量管理操作(几乎等于 root,避免赋予)
# CAP_CHOWN             修改文件所有者
# CAP_SETUID/SETGID     切换 UID/GID
# CAP_KILL              向任意进程发送信号
# CAP_NET_RAW           使用 RAW socket(ping 需要)

# 查看当前进程的 capabilities
cat /proc/self/status | grep Cap
# CapInh: 0000000000000000  继承 capabilities
# CapPrm: 0000000000000000  允许的 capabilities(位图)
# CapEff: 0000000000000000  有效 capabilities
# CapBnd: 000001ffffffffff  capabilities bounding set(上限)

# 解码 capabilities 位图
capsh --decode=000001ffffffffff

# Docker 默认 capabilities(--cap-add/--cap-drop)
# 默认包含:CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, SETGID, SETUID,
#           SETPCAP, NET_BIND_SERVICE, NET_RAW, SYS_CHROOT, MKNOD, AUDIT_WRITE,
#           SETFCAP
# 默认不包含:SYS_ADMIN, NET_ADMIN, SYS_PTRACE

# 运行时添加/移除 capability
docker run --cap-add NET_ADMIN myimage          # 添加(如需要 ip route)
docker run --cap-drop NET_RAW myimage           # 移除(禁止 ping)
docker run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx  # 最小权限

# 用 capsh 验证
capsh --print | grep Current

10. 手动构建一个容器:完整 Shell 脚本

将本章所有技术综合应用,用纯 Shell 手动创建一个运行 busybox 的容器:包含 namespace 隔离、overlayfs 文件系统、cgroup 资源限制、veth 网络接入。这就是 runc 做的事情的简化版。

#!/usr/bin/env bash
# mini-container.sh — 手动构建容器(需要 root,Linux 5.x+)
# 用法:sudo ./mini-container.sh

set -euo pipefail

CONTAINER_ID="mc-$(head -c4 /dev/urandom | xxd -p)"
BASE_DIR="/tmp/minicontainer/$CONTAINER_ID"
BUSYBOX_IMAGE="/tmp/busybox-rootfs"    # 需要提前准备

log() { echo "[$(date +%T)] $*"; }
cleanup() {
  log "Cleaning up $CONTAINER_ID ..."
  umount "$BASE_DIR/merged" 2>/dev/null || true
  ip link delete "veth-h-$CONTAINER_ID" 2>/dev/null || true
  ip netns delete "ns-$CONTAINER_ID" 2>/dev/null || true
  rmdir /sys/fs/cgroup/"$CONTAINER_ID" 2>/dev/null || true
  rm -rf "$BASE_DIR"
  log "Done."
}
trap cleanup EXIT

# === 步骤1:准备 busybox rootfs ===
if [ ! -d "$BUSYBOX_IMAGE" ]; then
  log "Preparing busybox rootfs ..."
  mkdir -p "$BUSYBOX_IMAGE"
  # 使用 Docker 导出 busybox 文件系统(如果有 Docker)
  if command -v docker &>/dev/null; then
    docker export "$(docker create busybox)" | tar -C "$BUSYBOX_IMAGE" -xf -
  else
    # 手动下载 busybox 静态二进制
    mkdir -p "$BUSYBOX_IMAGE/bin"
    wget -qO "$BUSYBOX_IMAGE/bin/busybox" \
      "https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"
    chmod +x "$BUSYBOX_IMAGE/bin/busybox"
    # 创建常用命令的符号链接
    for cmd in sh ls ps cat echo mount umount; do
      ln -sf busybox "$BUSYBOX_IMAGE/bin/$cmd"
    done
    mkdir -p "$BUSYBOX_IMAGE"/{proc,sys,dev,tmp,etc}
    echo "nameserver 8.8.8.8" > "$BUSYBOX_IMAGE/etc/resolv.conf"
    echo "root:x:0:0:root:/root:/bin/sh" > "$BUSYBOX_IMAGE/etc/passwd"
  fi
fi

# === 步骤2:创建 overlayfs 文件系统 ===
log "Setting up overlayfs for $CONTAINER_ID ..."
mkdir -p "$BASE_DIR"/{upper,work,merged}

mount -t overlay overlay \
  -o "lowerdir=$BUSYBOX_IMAGE,upperdir=$BASE_DIR/upper,workdir=$BASE_DIR/work" \
  "$BASE_DIR/merged"

# 准备 merged 中的伪文件系统挂载点
mkdir -p "$BASE_DIR/merged"/{proc,sys,dev}

# === 步骤3:创建 network namespace 和 veth pair ===
log "Setting up network for $CONTAINER_ID ..."

NETNS="ns-$CONTAINER_ID"
VETH_HOST="veth-h-$CONTAINER_ID"
VETH_CONT="veth-c-$CONTAINER_ID"
HOST_IP="10.200.0.1"
CONT_IP="10.200.0.2"

ip netns add "$NETNS"
ip link add "$VETH_HOST" type veth peer name "$VETH_CONT"
ip link set "$VETH_CONT" netns "$NETNS"

# 配置宿主机端
ip addr add "$HOST_IP/24" dev "$VETH_HOST" 2>/dev/null || true
ip link set "$VETH_HOST" up

# 配置容器端(在 netns 中)
ip netns exec "$NETNS" ip addr add "$CONT_IP/24" dev "$VETH_CONT"
ip netns exec "$NETNS" ip link set "$VETH_CONT" up
ip netns exec "$NETNS" ip link set lo up
ip netns exec "$NETNS" ip route add default via "$HOST_IP"

# 开启 NAT(让容器能访问外网)
iptables -t nat -A POSTROUTING -s "10.200.0.0/24" -o eth0 -j MASQUERADE 2>/dev/null || true

log "Container IP: $CONT_IP, Host IP: $HOST_IP"

# === 步骤4:创建 cgroup 并设置资源限制 ===
log "Configuring cgroup for $CONTAINER_ID ..."

CG_PATH="/sys/fs/cgroup/$CONTAINER_ID"
mkdir -p "$CG_PATH"

# 启用控制器(需要父 cgroup 允许)
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true

# CPU: 最多使用 25%(250ms/1000ms)
echo "250000 1000000" > "$CG_PATH/cpu.max" 2>/dev/null || true

# Memory: 最多 128MiB
echo $((128 * 1024 * 1024)) > "$CG_PATH/memory.max" 2>/dev/null || true
echo $((128 * 1024 * 1024)) > "$CG_PATH/memory.swap.max" 2>/dev/null || true

# pids: 最多 50 个进程
echo 50 > "$CG_PATH/pids.max" 2>/dev/null || true

log "Resource limits: CPU=25%, Memory=128MiB, PIDs=50"

# === 步骤5:启动容器进程(综合所有 namespace)===
log "Starting container $CONTAINER_ID ..."
log "Root filesystem: $BASE_DIR/merged"
echo "---"

# unshare 创建所有 namespace,在容器内执行启动脚本
ROOTFS="$BASE_DIR/merged"
NEWROOT="$ROOTFS"

# 使用 unshare 创建独立的 namespace 环境
# --pid --fork: 新 PID namespace,fork 出子进程
# --mount: 新 mount namespace(避免影响宿主机)
# --uts: 新 hostname namespace
# --ipc: 新 IPC namespace
# 注意:network namespace 已通过 ip netns 创建,这里通过 /var/run/netns 绑定

unshare \
  --pid \
  --fork \
  --mount \
  --uts \
  --ipc \
  -- \
  /bin/bash -c "
    set -e

    # 设置 hostname
    hostname '$CONTAINER_ID'

    # 切换到容器根文件系统
    cd '$NEWROOT'

    # 挂载 /proc(在新 PID namespace 中必须重新挂载)
    mount -t proc proc '$NEWROOT/proc'

    # 挂载 /dev(bind mount 宿主机 /dev 以获得设备访问)
    mount --bind /dev '$NEWROOT/dev'

    # 进入 network namespace(加入之前创建的 netns)
    # 通过 nsenter 方式加入 netns 需要在 exec 之前处理
    # 这里简化:在 chroot 内网络配置继承自 netns

    # 使用 pivot_root 切换根文件系统(比 chroot 更安全)
    mkdir -p '$NEWROOT/.oldroot'
    mount --bind '$NEWROOT' '$NEWROOT'
    pivot_root '$NEWROOT' '$NEWROOT/.oldroot'

    # 卸载旧根
    umount -l /.oldroot
    rmdir /.oldroot

    # 进入容器 shell
    echo '=== Welcome to MiniContainer ==='
    echo '=== Container ID: $CONTAINER_ID ==='
    exec /bin/sh
  " &

CONTAINER_PID=$!

# 将容器进程加入 cgroup
echo "$CONTAINER_PID" > "$CG_PATH/cgroup.procs" 2>/dev/null || true

# 将容器进程加入 network namespace
# 使用 nsenter 将容器进程迁移到创建的 netns
nsenter -t "$CONTAINER_PID" --net=/var/run/netns/"$NETNS" true 2>/dev/null || true

log "Container PID on host: $CONTAINER_PID"
log "cgroup: $CG_PATH"
log "Press Ctrl+C or type 'exit' in container to stop."

# 等待容器进程退出
wait "$CONTAINER_PID" || true
log "Container $CONTAINER_ID exited."

脚本说明: 此脚本演示了容器的核心实现原理,生产中使用 runc/crun(OCI 标准运行时)或 containerd。runc 是 Docker/Kubernetes 的默认运行时,实现了完整的 OCI(Open Container Initiative)规范,包括:config.json 规范文件、完整的 seccomp/capabilities 支持、rootless 容器(user namespace)、cgroup 管理。运行命令:runc run mycontainer

容器技术全景总结: 一个完整的容器 = namespace 隔离(看不到宿主机进程/网络/文件系统)+ cgroup 限制(不能占满 CPU/内存)+ overlayfs(镜像分层,写时复制)+ pivot_root(安全的根文件系统切换)+ seccomp(只允许必要 syscall)+ capabilities(最小权限 root)。Docker、Podman、containerd 都是在这些内核原语之上构建的高级工具。

  上一章
  ← 第15章:安全加固


  下一章
  第17章:系统调用 →
本章评分
4.5  / 5  (14 评分)

💬 留言讨论