欢迎光临

Linux 容器技术深度解析:从 cgroups 底层原理到生产环境安全调优实战

前言:理解容器技术的基石

Docker 和 Kubernetes 早已成为现代基础设施的标配,但当你使用

1
docker run

启动一个容器时,底层到底发生了什么?容器之所以”轻量”而非像虚拟机一样需要完整的内核模拟,秘密就在于 Linux 内核的两大核心特性:cgroups(控制组)namespaces(命名空间)。理解这两者,才能真正掌握容器技术的底层逻辑。

本文将从零开始,先用命令行的方式手把手创建”手工容器”,然后深入 cgroups 和 namespace 的配置调优,最后讨论容器安全性、资源限制的最佳实践。目标读者是有一定 Linux 基础、希望深入理解容器的开发者和运维人员。

Linux 容器技术概览

一、Linux Namespace:实现资源隔离

Namespace 是 Linux 内核实现资源隔离的机制。它让一组进程”看到”自己独有的系统资源视图,而与其他进程隔离。每个 namespace 提供的隔离维度都不同,Docker 正是组合利用了多个 namespace 实现容器的独立环境。

1.1 六种核心 Namespace

Linux 内核目前提供了 8 种 namespace,其中容器技术最常用的是以下 6 种:

Namespace 隔离资源 内核版本
Mount (mnt) 文件系统挂载点 2.4.19+
PID (pid) 进程编号 2.6.24+
Network (net) 网络设备、IP、端口 2.6.29+
IPC (ipc) System V IPC 和 POSIX 消息队列 2.6.19+
UTS (uts) 主机名和域名 2.6.19+
User (user) 用户和用户组 ID 3.8+

1.2 使用 unshare 创建隔离环境

1
unshare

是 Linux 自带的工具,可以在新创建的 namespace 中运行程序。先用它感受一下 UTS namespace:


1
2
3
4
5
6
7
8
9
10
11
# 创建一个新 UTS namespace 并设置独立的主机名
sudo unshare --uts /bin/bash

# 在新 namespace 中修改主机名
hostname my-container
hostname
# 输出: my-container

# 在另一个终端检查宿主机的主机名
hostname
# 输出: (原来的主机名,没有变化)

同样的方式,我们可以组合多个 namespace 创建一个更完整的隔离环境:


1
2
3
4
5
6
7
8
# 同时创建 uts + pid + mount + ipc namespace
sudo unshare --uts --pid --mount --ipc --fork /bin/bash

# 注意:在 PID namespace 中,需要先挂载 /proc
mount -t proc none /proc

ps aux
# 输出中 PID 1 就是当前的 bash 进程

这就是 Docker 容器的基本原理!当你启动一个容器时,Docker 实际上是调用了类似

1
unshare

的系统调用

1
clone()

参考 clone(2) 手册,传入相应的 namespace 标志位来创建隔离环境。

二、Cgroups:实现资源限制与监控

Namespace 提供了”看到什么”的隔离,但无法限制”能用多少”——如果没有 cgroups,一个容器可以耗尽宿主机所有的 CPU 和内存。Cgroups(Control Groups)就是用来解决这个问题的。

2.1 Cgroups v1 vs v2

Linux 内核目前存在两套 cgroups 实现:

  • cgroups v1:每个资源类型(CPU、内存、IO 等)有独立的层级结构,管理复杂,容易导致不一致
  • cgroups v2:统一层级结构,更简洁安全。从 Ubuntu 21.10、Fedora 31、Debian 11、RHEL 9 开始默认使用

检查当前系统使用的版本:


1
2
3
4
5
6
# 检查 cgroup 版本
stat -fc %T /sys/fs/cgroup/
# cgroup2fs 表示 v2,tmpfs 表示 v1

# 或者
grep cgroup /proc/filesystems

2.2 手动创建 Cgroup 限制 CPU

以 cgroups v2 为例,手动创建控制组来限制进程的 CPU 使用:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1. 创建新的控制组
sudo mkdir /sys/fs/cgroup/example-container

# 2. 设置 CPU 配额:限制为 0.5 核(500000 微秒中的 250000 微秒)
echo "250000 500000" | sudo tee /sys/fs/cgroup/example-container/cpu.max

# 3. 启动一个消耗 CPU 的进程(例如一个无限循环)
dd if=/dev/zero of=/dev/null &
echo $!  # 记住 PID
# 假设 PID 为 12345

# 4. 将进程添加到控制组
echo 12345 | sudo tee /sys/fs/cgroup/example-container/cgroup.procs

# 5. 用 top 或 htop 验证 CPU 使用率被限制在 50% 左右
top -p 12345

2.3 Cgroups v1 的内存限制

使用 cgroups v1 限制内存的方式略有不同:


1
2
3
4
5
6
7
8
9
10
11
12
# 创建内存控制组
sudo mkdir /sys/fs/cgroup/memory/example-container

# 限制最大 512MB 内存(单位是字节)
echo "536870912" | sudo tee /sys/fs/cgroup/memory/example-container/memory.limit_in_bytes

# 超过限制时,内核会触发 OOM Killer
# 将进程写入控制组
echo 12345 | sudo tee /sys/fs/cgroup/memory/example-container/cgroup.procs

# 查看内存使用统计
cat /sys/fs/cgroup/memory/example-container/memory.usage_in_bytes

2.4 Cgroups v2 的内存限制

cgroups v2 的接口更加统一:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 创建控制组(自动继承父级)
sudo mkdir /sys/fs/cgroup/example-container

# 设置内存硬限制 512MB
echo "536870912" | sudo tee /sys/fs/cgroup/example-container/memory.max

# 设置内存软限制 256MB(尽力而为)
echo "268435456" | sudo tee /sys/fs/cgroup/example-container/memory.low

# 设置内存和 swap 总限制 1GB
echo "1073741824" | sudo tee /sys/fs/cgroup/example-container/memory.swap.max

# 允许使用 swap 但有限制,如需完全禁止 swap:
echo "0" | sudo tee /sys/fs/cgroup/example-container/memory.swap.max

三、组合 Namespace + Cgroups:手搓一个”容器”

现在我们把 namespace 和 cgroups 组合起来,完整地创建一个”手工容器”的运行环境。步骤如下:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#!/bin/bash
# my-container.sh - 手搓容器完整示例

CONTAINER_NAME="my-custom-container"
ROOTFS_DIR="/tmp/${CONTAINER_NAME}/rootfs"
CG_DIR="/sys/fs/cgroup/${CONTAINER_NAME}"

# 0. 准备 rootfs
mkdir -p "$ROOTFS_DIR"
# 用 debootstrap 或 alpine minirootfs 制作最小文件系统
curl -sL https://dl-cdn.alpinelinux.org/alpine/v3.19/releases/x86_64/alpine-minirootfs-3.19.1-x86_64.tar.gz \
  | tar -xz -C "$ROOTFS_DIR"

# 1. 创建 cgroup(v2)
mkdir -p "$CG_DIR"
echo "100000 200000" > "$CG_DIR/cpu.max"       # 0.5 CPU
echo "268435456" > "$CG_DIR/memory.max"          # 256MB 内存限制
echo "0" > "$CG_DIR/memory.swap.max"             # 禁止 swap

# 2. 创建 namespace 并启动容器进程
unshare --uts --pid --mount --ipc --net --fork \
  --mount-proc="$ROOTFS_DIR/proc" \
  /bin/bash << 'INNER'
    # 切换根文件系统
    exec chroot "$ROOTFS_DIR" /bin/sh -c "
      hostname my-container
      mount -t sysfs none /sys
      echo 'Container started!'
      # 将当前 shell PID 写入 cgroup(需要在宿主机执行)
      /bin/sh
    "
INNER

注:上面的简化脚本示意了组合方式。实际生产环境中,Docker 还使用了更多高级技术,包括 OverlayFS 联合文件系统、veth pair 网络设备、seccomp 安全策略等。

容器与 cgroups 架构图

四、容器资源限制实战调优

在生产环境使用 Docker 或 containerd 时,如何合理设置资源限制是运维同学关心的问题。以下是一些经过验证的最佳实践。

4.1 CPU 限制:不要使用分数核的误区

Docker 的

1
--cpus

参数表示容器可以使用的 CPU 时间比例,但很多开发者误以为设置

1
--cpus=1.5

就能保证容器获得 1.5 个 CPU 的性能。实际上,如果你的宿主机只有 4 核,设置

1
--cpus=4

也不一定能让容器独占所有 CPU 时间——这取决于其他容器的压力和内核调度策略。

更精细的控制方式:


1
2
3
4
5
6
7
8
9
10
# 保证容器至少获得 0.5 核,最多 2 核
docker run --cpus=2 --cpu-shares=512 my-image

# 绑定到特定 CPU 核心(CPU pinning)
docker run --cpuset-cpus="0-1" my-image

# 设置 CPU 的权重优先级(相对于其他容器)
docker run --cpu-shares=1024 high-priority-container
docker run --cpu-shares=256 low-priority-container
# 当宿主机 CPU 争用时,高优先级的容器获得更多 CPU 时间

4.2 内存限制:OOM 优先级控制

当宿主机内存不足时,内核 OOM Killer 会选择杀掉某些进程。容器的 OOM 优先级可以通过以下方式控制:


1
2
3
4
5
6
7
8
9
10
11
12
# 降低容器被 OOM Killer 选中的概率
docker run --oom-kill-disable=false --oom-score-adj=-500 my-database

# 关键业务容器设置更低的 oom_score_adj(更不容易被杀)
docker run --oom-score-adj=-1000 critical-service

# 批处理任务可以设置较高的值(优先被杀)
docker run --oom-score-adj=500 batch-job

# 注意:--oom-kill-disable 与 memory 限制同时设置才有意义
# 仅设置 --oom-kill-disable 而不设 memory 限制的话,容器理论上可以耗尽宿主机内存
docker run --memory=512m --oom-kill-disable my-service

4.3 磁盘 I/O 限制

对于日志密集型和数据库容器,磁盘 IO 的限制至关重要:


1
2
3
4
5
6
7
8
9
# 限制读写速率(字节/秒)
docker run --device-read-bps=/dev/sda:50mb \
           --device-write-bps=/dev/sda:30mb \
           my-db-container

# 限制 IOPS
docker run --device-read-iops=/dev/sda:1000 \
           --device-write-iops=/dev/sda:500 \
           my-log-processor

五、容器运行时安全实践

Namespace 和 cgroups 提供了基础的隔离和限制,但容器并非完全安全的沙箱。以下安全措施能显著降低容器逃逸的风险。

5.1 Seccomp 系统调用过滤

Seccomp(Secure Computing Mode)可以限制容器内进程可以使用的系统调用,是防范容器逃逸的第一道防线。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Docker 默认使用 seccomp 白名单,约 300+ 系统调用被允许
# 查看当前容器的 seccomp 配置
docker inspect --format='{{json .HostConfig.SecurityOpt}}' container_name

# 使用自定义 seccomp 策略(限制更严格)
docker run --security-opt seccomp=/path/to/custom-profile.json my-app

# 自定义 profile 示例(仅允许必要的系统调用)
cat > /tmp/strict-seccomp.json << 'EOF'
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": ["read", "write", "open", "close", "mmap", "munmap",
                "brk", "sched_yield", "exit_group", "exit", "futex",
                "clock_gettime", "getdents64", "newfstatat", "readlinkat",
                "getrandom", "nanosleep", "writev", "readv", "mprotect"],
      "action": "SCMP_ACT_ALLOW",
      "args": [],
      "comment": "Allowed system calls for minimal workloads"
    }
  ]
}
EOF

5.2 Capabilities 缩减

Linux capabilities 将 root 用户的特权拆分为独立单元。容器默认启用了约 14 种 capability,对于大多数应用来说仍过多:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 查看容器拥有的 capabilities
docker run --rm alpine capsh --print

# 仅保留真正需要的 capability(安全最佳实践)
docker run --cap-drop=ALL \
           --cap-add=NET_BIND_SERVICE \
           --cap-add=NET_RAW \
           nginx

# 常见应用的 capabilities 需求速查表
# - Web 服务器 (nginx/httpd): NET_BIND_SERVICE, NET_RAW
# - 数据库 (PostgreSQL): 无需额外 capability
# - Redis: 无需额外 capability
# - NTP 服务: SYS_TIME, SYS_NICE
# - DHCP 客户端: NET_BIND_SERVICE, NET_RAW, NET_ADMIN

5.3 使用 Rootless 模式

Docker 的 rootless 模式可以在非 root 用户下运行整个 Docker 守护进程,大幅降低潜在的攻击面:


1
2
3
4
5
6
7
8
9
# 安装 rootless Docker
dockerd-rootless-setuptool.sh install

# 使用 rootless Docker
export DOCKER_HOST=unix:///run/user/$UID/docker.sock
docker run -d nginx

# 验证当前是否是 rootless 模式
docker info 2>&1 | grep -i rootless

要注意的是,rootless 模式也有一些限制:参见 Docker 官方文档,例如 overlay network 不支持,

1
--privileged

模式不可用,以及性能开销略高(约 5-10%)。

六、生产环境资源规划清单

将容器部署到生产环境时,建议按照以下清单检查资源配置:

  • 内存:设置
    1
    memory

    硬限制,同时设置

    1
    memory-reservation

    软限制(给调度器参考值),通常硬限制比应用预期使用量多 20-30%

  • CPU:使用
    1
    --cpus

    而非

    1
    --cpu-shares

    ,除非你有明确的优先级需求。对延迟敏感的应用使用

    1
    --cpuset-cpus

    绑定核心

  • 文件系统:日志密集型容器挂载独立数据卷,设置磁盘 IO 限制防止影响其他容器
  • 安全
    1
    --cap-drop=ALL

    然后逐一添加需要的 capability;启用 seccomp 默认配置;除非极特殊情况否则不要使用

    1
    --privileged
  • 健康检查
    1
    HEALTHCHECK

    指令配置合理的间隔(30s)和超时(10s),重试次数 3 次

  • 重启策略:对我来说,非关键服务用
    1
    --restart=on-failure:5

    ,关键服务用

    1
    --restart=always

七、故障排查:cgroups 和 namespace 相关工具

当容器行为异常时,以下工具能帮助你透过现象看本质:

工具 用途 示例
1
lsns
列出系统上的所有 namespace
1
lsns -t net

查看所有 net namespace

1
nsenter
进入已有 namespace 执行命令
1
nsenter -t 12345 -n ip addr

查看某个进程的网络配置

1
lsblk --fs
查看文件系统挂载点和设备
1
lsblk --fs | grep overlay

查看 overlay 层

1
systemd-cgtop
类似 top 但显示 cgroup 资源使用
1
systemd-cgtop

直接运行

1
cat /proc/PID/cgroup
查看进程所属的 cgroup
1
cat /proc/$$/cgroup

看当前 shell 的 cgroup

1
capsh --print
查看当前进程的 capabilities
1
capsh --print

需要在容器内执行


1
2
3
4
5
6
7
8
9
10
11
12
# 实际排查场景:查找哪个容器占用了大量内存
# 方法1:通过 cgroup 查找
for cg in /sys/fs/cgroup/system.slice/docker-*.scope; do
    echo "=== $(basename $cg) ==="
    cat "$cg/memory.current" 2>/dev/null | numfmt --to=iec
done

# 方法2:通过 nsenter 诊断容器网络问题
# 先获取容器的主进程 PID
CONTAINER_PID=$(docker inspect --format '{{.State.Pid}}' container_name)
# 进入容器的网络 namespace 抓包
nsenter -t "$CONTAINER_PID" -n tcpdump -i eth0 -c 100

八、总结与延伸阅读

本文从 namespace 和 cgroups 的底层机制出发,逐步过渡到容器资源限制的生产实践和安全管理。理解这些底层原理,不仅能帮助你更好地配置 Docker/Kubernetes,也能在遇到奇葩问题时直击本质——大多数容器的”玄学”问题,最终都可以归结为 namespace 隔离不彻底或 cgroup 限制配置不当。

延伸阅读资源

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Linux 容器技术深度解析:从 cgroups 底层原理到生产环境安全调优实战
分享到: 更多 (0)