部落格好读版

在上一章节,我们介绍了 Linux 的 cgroup(Control Groups)技术用于资源限制的概念。本章将聚焦于 Docker 容器,通过实验探索资源限制的实际效果和特性。


开始之前

在该使之前,让我们先準备好测试环境和工具。

Docker 资源限制指令

首先,通过以下指令查看 Docker 提供的资源限制参数:

docker container run --help
#
[...]
-c, --cpu-shares int CPU shares (relative weight)
--cpus decimal Number of CPUs
--cpuset-cpus string CPUs in which to allow execution (0-3, 0,1)
--cpuset-mems string MEMs in which to allow execution (0-3, 0,1)
[...]
-m, --memory bytes Memory limit
--memory-reservation bytes Memory soft limit
--memory-swap bytes Swap limit equal to memory plus swap: \'-1\' to enable
unlimited swap
[...]

其中,--memory-reservation 的描述中提到它是一种「软性限制」,怎么记忆体也有「软性限制」?

实际上,它更接近于「记忆体保留」的功能:

  • 记忆体充足时,容器可以超过此限制使用更多记忆体。
  • 记忆体紧张时,Docker 会尝试回收记忆体,将容器使用量控制在此限制内。
  • 适用于「基线需求」场景,确保容器拥有足够的记忆体启动或运行。

这与 Kubernetes 中的 request 和 limit 概念一致,可参考 Kubernetes Limit Range 官方文件。


压力测试工具

stress-ng 是一款强大的 Linux 压力测试工具,支持模拟多种资源负载(如 CPU、内存、I/O 和网络),帮助用户测试系统在高负载下的性能和稳定性。

Docker Image 构建

由于基础镜像 Alpine 不包含 stress-ng,我们需要手动构建。

FROM alpine:latest

# 安装必要工具
RUN apk update && apk add --no-cache stress-ng

# 设置 ENTRYPOINT 使容器启动时执行压力测试命令
ENTRYPOINT ["stress-ng"]

执行以下指令构建镜像:

docker image build -f Dockerfile.stress -t stress:alpine .

记忆体测试 Image 建构

为了更好地测试记忆体限制,我们构建一个专用镜像,包含自定义脚本。

脚本:run.sh

#!/usr/bin/env sh

timeout 20 sh -c \'
used=0
for i in $(seq 1 10); do
sleep 2
used=$((used + 100))
stress-ng --vm 1 --vm-bytes 100M --vm-keep --quiet &
echo "Used Memory: ${used}M"
done
wait
\'

说明每隔 2 秒新增一个 100M 记忆体佔用的 stress-ng 工作,并累加输出当前已用记忆体量。

范例输出:

Used Memory: 100M
Used Memory: 200M
Used Memory: 300M
...
Used Memory: 1000M

Dockerfile

FROM alpine:latest

# 安装必要工具
RUN apk update && apk add --no-cache stress-ng

# 复制脚本到容器
COPY run.sh /usr/local/bin/run.sh

# 确保脚本可执行
RUN chmod +x /usr/local/bin/run.sh

# 设置 ENTRYPOINT
ENTRYPOINT ["/usr/local/bin/run.sh"]

构建指令:

docker build -f Dockerfile.stress.memory -t stress:memory .


观察工具

在测试过程中,使用以下工具监控资源使用状况:

  • htop:交互式系统监控工具,用于查看 CPU、内存等系统资源使用情况。
  • docker stats:显示运行中容器的资源使用情况。
  • tail -f /var/log/syslog | grep -i "oom":监控系统日誌中有关 OOM(Out of Memory)的记录。

  • 实验一:限制 CPU 资源

    测试 1:限制至 0.5 个 CPU

    docker container run -it --rm --name stress --cpus=0.5 stress:alpine -c 1 -t 10

    结果:

    测试 2:限制至 1 个 CPU

    docker container run -it --rm --name stress --cpus=1 stress:alpine -c 1 -t 10

    结果:

    测试 3:限制至 1 个 CPU,负载增加至 2 个 CPU

    docker container run -it --rm --name stress --cpus=1 stress:alpine -c 2 -t 10

    结果:

    测试 4:限制至 1.5 个 CPU,负载增加至 2 个 CPU

    docker container run -it --rm --name stress --cpus=1.5 stress:alpine -c 2 -t 10

    结果:

    观察结果:
    即使压力测试软件将负载拉满,基于 cgroup 的执行环境仍不能突破容器设置的资源限制。

    实验二:CPU 的软性限制

    在 CPU 资源限制中,容器执行时间由作业系统分配的运行时间决定。以下实验旨在验证这一点。

    测试指令

    time sh -c \'for i in $(seq 1 3000000); do :; done\'

    上述指令执行一个简单的空迴圈,重复 3,000,000 次,并测量执行时间。

    范例输出:

    real 0m0.882s
    user 0m0.728s
    sys 0m0.199s

    • real:实际运行时间(包含所有等待时间)。
    • user:CPU 在用户模式下执行的时间。
    • sys:CPU 在核心模式下执行的时间(如 I/O 操作)。

    容器中执行测试

    通过以下指令将测试整合至容器:

    docker container run -it --rm --name stress --cpus=1 --entrypoint ash stress:alpine -c "time sh -c \'for i in \\$(seq 1 3000000); do :; done\'"

    对照组:--cpus=1

    在限制为 1 个 CPU 的容器中执行上述指令:

    实验组:--cpus=0.5

    在限制为 0.5 个 CPU 的容器中执行:


    结论:

    • 程式的 实际执行时间(user 时间) 不会因为 CPU 限制而改变。
    • 当限制 CPU 资源时,程式会因 等待执行的时间增加,导致整体执行时间(real 时间)变长。
      这与 CPU 使用权限的分配机制相吻合。

    实验三:CPU 的权重调度

    cgroup 使用 cpu-share 来决定 CPU 资源的分配权重。通过 Docker 提供的 --cpu-shares 参数,我们可以调整容器的资源权重。

    测试指令

    docker container run -it --rm --name stress --cpu-shares=256 stress:alpine

    若未指定 --cpu-shares,容器的预设值为 1024。这是一个相对权重,与其他容器的 cpu-share 比例共同决定 CPU 分配。

    资源分配公式

    当系统资源不足时,cpu-share 决定了 CPU 分配的比例:


    测试 1:资源充足情况下,权重对分配无影响

    docker-compose.yml

    version: \'2\'
    services:
    stress:
    image: stress:alpine
    container_name: stress
    command: ["-c", "1", "-t", "10"]
    cpu_shares: 256

    stress2:
    image: stress:alpine
    container_name: stress2
    command: ["-c", "1", "-t", "10"]
    cpu_shares: 512

    stress3:
    image: stress:alpine
    container_name: stress3
    command: ["-c", "1", "-t", "10"]
    cpu_shares: 768

    stress4:
    image: stress:alpine
    container_name: stress4
    command: ["-c", "1", "-t", "10"]
    cpu_shares: 1024

    执行结果:
    在资源充足时,权重对容器的 CPU 使用没有影响。


    测试 2:资源不足时的权重分配

    设定 WSL 资源限制

    在 Windows Host 的 %UserProfile%\\.wslconfig 文件中添加以下配置:

    [wsl2]
    memory=4GB
    processors=2

    重新启动 WSL 后,资源限制生效。

    再次执行 测试 1 中的 docker-compose.yml,观察结果:


    测试 3:默认 cpu-share 值

    根据 Docker 官方文档,未设置 cpu-shares 的容器将採用默认值 1024。我们来实验看看。

    稍微修改 docker-compose.yml,移除 stress4 的 cpu-shares 配置:

    version: \'2\'
    services:
    stress:
    image: stress:alpine
    container_name: stress
    command: ["-c", "1", "-t", "10"]
    cpu_shares: 256

    stress2:
    image: stress:alpine
    container_name: stress2
    command: ["-c", "1", "-t", "10"]
    cpu_shares: 512

    stress3:
    image: stress:alpine
    container_name: stress3
    command: ["-c", "1", "-t", "10"]
    cpu_shares: 768

    stress4:
    image: stress:alpine
    container_name: stress4
    command: ["-c", "1", "-t", "10"]

    执行结果:
    未设定 cpu-shares 的容器默认值为 1024,其分配的 CPU 资源比例高于其他容器。

    观察结果从实验结果来看,没有设定 cpu-shares 的容器,在资源抢夺上的优先度是高于有设定 cpu-shares 容器。


    结论:

    • 在资源不足的情况下,容器的 CPU 分配比例由 cpu-shares 决定。权重较高的容器将获得更多的 CPU 资源。
    • 未设定 cpu-shares 的容器,在资源抢夺上的优先度高于有设定 cpu-shares 容器。

    实验四:限制记忆体资源

    记忆体资源限制是容器资源控制的重要部分。以下实验将展示如何使用 Docker 限制容器记忆体,并观察系统的行为。


    测试 1:不限制记忆体

    当未设定记忆体限制时,容器预设可以使用宿主机的所有可用记忆体。

    测试指令:

    docker container run -it --rm --name stress stress:memory

    结果:


    测试 2:限制记忆体使用量

    将容器的记忆体限制为 300MB,观察系统的行为。

    测试指令:

    docker container run -it --rm --name stress --memory 300m stress:memory

    结果:


    观察现象:

    • 容器记忆体耗尽后,并未触发 OOM(Out of Memory)Kill。
      相反,容器的 Swap 使用量开始上升。

    分析

    Swap 是 Linux 系统的虚拟记忆体,使用磁盘空间模拟记忆体,因此在记忆体耗尽时,会切换到使用 Swap。当未限制容器的 Swap 使用量时,容器可以使用的 Swap 等于设置的记忆体限制(此例为 300MB)。

    至于为什么 Swap 使用量也耗尽后,为什么还是没触发 OOM Kill 呢? 其实它已经发生了,下面我们来验证。


    测试 3:限制记忆体与 Swap 使用量

    进一步限制容器的总记忆体(物理记忆体 + Swap)为 300MB,模拟更严格的限制条件。

    系统监控

    在宿主机中开启新终端,执行以下指令监控 OOM 现象:

    tail -f /var/log/syslog | grep -i "oom"

    测试指令:

    docker container run -it --rm --name stress --memory 300m --memory-swap 300m stress:memory

    • --memory 限制物理记忆体。
    • --memory-swap 限制总记忆体(物理记忆体 + Swap)。

    结果:


    观察现象:

    • 当容器记忆体与 Swap 使用达到限制时,触发 OOM Kill。
    • 容器内的某些进程被强制终止,但容器未停止运行。

    分析:为何容器未被关闭?

    这牵扯到两个概念:

    • 在 Docker 中,容器的 主进程(PID=1) 若未被 OOM Kill,则容器仍会保持运行状态。详情参考 Tracking Down “Invisible” OOM Kills in Kubernetes
    • 使用 stress-ng 压力测试工具时,它作为主进程(PID=1)启动。压力测试石的 Worker 则是 stress-ng 产生的子进程。

    也就是说,当我们使用 stress-ng 测试,发生 OOM Kill 的进程并不是主进程,而是其他子进程,难怪容器不会被杀掉。

    为了验证这个说法,我们进行下一个实验。


    测试 4:模拟主进程触发 OOM

    为验证主进程 OOM 时容器的行为,我们使用 Python 编写脚本进行实验。

    memory_stress_test.py

    import sys
    import time

    def trigger_oom():
    # 创建一个巨大的列表来快速消耗记忆体
    memory_list = []
    current_size = 0

    print("开始触发 OOM...")

    while True:
    try:
    # 每次分配约 100MB 的记忆体
    chunk = [\'x\'] * (25 * 1024 * 1024)
    memory_list.append(chunk)
    current_size += 100

    print(f"当前已分配记忆体: {current_size} MB")
    time.sleep(0.5)

    except MemoryError:
    print("记忆体分配失败")
    break

    if __name__ == "__main__":
    trigger_oom()

    Dockerfile

    FROM python:3.9-slim

    WORKDIR /app

    COPY memory_stress_test.py .

    CMD ["python", "memory_stress_test.py"]

    构建指令:

    docker build -f Dockerfile.stress.memory.python -t stress:memory-py .


    测试指令

    docker run -it --memory=500m stress:memory-py

    结果:


    确认 OOM 状态

    检查容器的状态:

    docker container ls -a

    输出范例:

    CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
    5f3679d1c217 stress:memory-py "python memory_stres…" 34 seconds ago Exited (137) 31 seconds ago heuristic_elbakyan

    查看详细资讯

    使用 docker inspect 查看容器的结束状态:

    docker container inspect 5f3679d1c217 --format \'{{json .State}}\' | jq

    结果范例:

    {
    "Status": "exited",
    "Running": false,
    "Paused": false,
    "Restarting": false,
    "OOMKilled": true,
    "Dead": false,
    "Pid": 0,
    "ExitCode": 137,
    "Error": "",
    "StartedAt": "2024-12-10T11:20:50.956018772Z",
    "FinishedAt": "2024-12-10T11:20:52.491126925Z"
    }

    解读:

    • ExitCode 137 表示容器因 OOM 而退出。
    • OOMKilled 为 true,证明主进程触发 OOM 导致容器被终止。

    结论

    通过上述实验,我们证实了容器内的资源限制功能如何影响应用运行:

  • CPU 资源限制:影响程式执行时间的等待部分,而非实际执行时间。
  • 记忆体资源限制:触发 OOM Kill 的条件与主进程和子进程的行为密切相关。
  • Swap 的作用:在记忆体不足时,未限制的 Swap 会延缓 OOM 的发生。
  • 希望这篇文章能帮助你更深入理解 Docker 的资源限制机制,并灵活应用于实际场景。

    参考

    • https://github.com/opencontainers/runc/blob/main/docs/cgroup-v2.md
    • https://kubernetes.io/zh-cn/docs/concepts/architecture/cgroups/#cgroup-v2
    • https://itplus.ithome.com.tw/webinar-page/239
    • https://koding.work/how-to-use-docker-cpu-resource-limit/
    • https://docs.docker.com/engine/containers/resource_constraints
    • https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/6/html/resource_management_guide/sec-cpu
    • https://www.agileconnection.com/article/overview-linux-exit-codes