部落格好读版
在上一章节,我们介绍了 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 .
观察工具
在测试过程中,使用以下工具监控资源使用状况:
实验一:限制 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 导致容器被终止。
结论
通过上述实验,我们证实了容器内的资源限制功能如何影响应用运行:
希望这篇文章能帮助你更深入理解 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