Vinc3nt's Life

探索容器資源限制:透過實驗理解 Docker 的 CPU 和記憶體調度 (2)

2024-12-10
develop
linux
docker
cgroup
container
oom
最後更新:2025-01-26
14分鐘
2718字

在上一章節,我們介紹了 Linux 的 cgroup(Control Groups)技術用於資源限制的概念。本章將聚焦於 Docker 容器,通過實驗探索資源限制的實際效果和特性。


開始之前

在該使之前,讓我們先準備好測試環境和工具。

Docker 資源限制指令

首先,通過以下指令查看 Docker 提供的資源限制參數:

Terminal window
1
docker container run --help
2
#
3
[...]
4
-c, --cpu-shares int CPU shares (relative weight)
5
--cpus decimal Number of CPUs
6
--cpuset-cpus string CPUs in which to allow execution (0-3, 0,1)
7
--cpuset-mems string MEMs in which to allow execution (0-3, 0,1)
8
[...]
9
-m, --memory bytes Memory limit
10
--memory-reservation bytes Memory soft limit
11
--memory-swap bytes Swap limit equal to memory plus swap: '-1' to enable
12
unlimited swap
13
[...]

其中,--memory-reservation 的描述中提到它是一種「軟性限制」,怎麼記憶體也有「軟性限制」?

實際上,它更接近於「記憶體保留」的功能:

  • 記憶體充足時,容器可以超過此限制使用更多記憶體。
  • 記憶體緊張時,Docker 會嘗試回收記憶體,將容器使用量控制在此限制內。
  • 適用於「基線需求」場景,確保容器擁有足夠的記憶體啟動或運行。

這與 Kubernetes 中的 requestlimit 概念一致,可參考 Kubernetes Limit Range 官方文件


壓力測試工具

stress-ng 是一款強大的 Linux 壓力測試工具,支持模擬多種資源負載(如 CPU、內存、I/O 和網絡),幫助用戶測試系統在高負載下的性能和穩定性。

Docker Image 構建

由於基礎鏡像 Alpine 不包含 stress-ng,我們需要手動構建。

Dockerfile.stress
1
FROM alpine:latest
2
3
# 安裝必要工具
4
RUN apk update && apk add --no-cache stress-ng
5
6
# 設置 ENTRYPOINT 使容器啟動時執行壓力測試命令
7
ENTRYPOINT ["stress-ng"]

執行以下指令構建鏡像:

Terminal window
1
docker image build -f Dockerfile.stress -t stress:alpine .

記憶體測試 Image 建構

為了更好地測試記憶體限制,我們構建一個專用鏡像,包含自定義腳本。

腳本:run.sh

run.sh
1
#!/usr/bin/env sh
2
3
timeout 20 sh -c '
4
used=0
5
for i in $(seq 1 10); do
6
sleep 2
7
used=$((used + 100))
8
stress-ng --vm 1 --vm-bytes 100M --vm-keep --quiet &
9
echo "Used Memory: ${used}M"
10
done
11
wait
12
'

說明 每隔 2 秒新增一個 100M 記憶體佔用的 stress-ng 工作,並累加輸出當前已用記憶體量。

範例輸出:

Terminal window
1
Used Memory: 100M
2
Used Memory: 200M
3
Used Memory: 300M
4
...
5
Used Memory: 1000M

Dockerfile

Dockerfile.stress.memory
1
FROM alpine:latest
2
3
# 安裝必要工具
4
RUN apk update && apk add --no-cache stress-ng
5
6
# 複製腳本到容器
7
COPY run.sh /usr/local/bin/run.sh
8
9
# 確保腳本可執行
10
RUN chmod +x /usr/local/bin/run.sh
11
12
# 設置 ENTRYPOINT
13
ENTRYPOINT ["/usr/local/bin/run.sh"]

構建指令:

Terminal window
1
docker build -f Dockerfile.stress.memory -t stress:memory .

觀察工具

在測試過程中,使用以下工具監控資源使用狀況:

  1. htop:交互式系統監控工具,用於查看 CPU、內存等系統資源使用情況。
  2. docker stats:顯示運行中容器的資源使用情況。
  3. tail -f /var/log/syslog | grep -i "oom":監控系統日誌中有關 OOM(Out of Memory)的記錄。

實驗一:限制 CPU 資源

測試 1:限制至 0.5 個 CPU

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

結果:

default

測試 2:限制至 1 個 CPU

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

結果:

default

測試 3:限制至 1 個 CPU,負載增加至 2 個 CPU

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

結果:

default

測試 4:限制至 1.5 個 CPU,負載增加至 2 個 CPU

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

結果:

default

觀察結果
即使壓力測試軟體將負載拉滿,基於 cgroup 的執行環境仍不能突破容器設置的資源限制。

實驗二:CPU 的軟性限制

在 CPU 資源限制中,容器執行時間由作業系統分配的運行時間決定。以下實驗旨在驗證這一點。

測試指令

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

上述指令執行一個簡單的空迴圈,重複 3,000,000 次,並測量執行時間。

範例輸出:

Terminal window
1
real 0m0.882s
2
user 0m0.728s
3
sys 0m0.199s
  • real:實際運行時間(包含所有等待時間)。
  • user:CPU 在用戶模式下執行的時間。
  • sys:CPU 在核心模式下執行的時間(如 I/O 操作)。

容器中執行測試

通過以下指令將測試整合至容器:

Terminal window
1
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 的容器中執行上述指令:

default

實驗組:--cpus=0.5

在限制為 0.5 個 CPU 的容器中執行:

default


結論

  • 程式的 實際執行時間(user 時間) 不會因為 CPU 限制而改變。
  • 當限制 CPU 資源時,程式會因 等待執行的時間增加,導致整體執行時間(real 時間)變長。
    這與 CPU 使用權限的分配機制相吻合。

實驗三:CPU 的權重調度

cgroup 使用 cpu-share 來決定 CPU 資源的分配權重。通過 Docker 提供的 --cpu-shares 參數,我們可以調整容器的資源權重。

測試指令

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

若未指定 --cpu-shares,容器的預設值為 1024。這是一個相對權重,與其他容器的 cpu-share 比例共同決定 CPU 分配。

資源分配公式

當系統資源不足時,cpu-share 決定了 CPU 分配的比例:

default


測試 1:資源充足情況下,權重對分配無影響

docker-compose.yml

docker-compose.yml
1
version: '2'
2
services:
3
stress:
4
image: stress:alpine
5
container_name: stress
6
command: ["-c", "1", "-t", "10"]
7
cpu_shares: 256
8
9
stress2:
10
image: stress:alpine
11
container_name: stress2
12
command: ["-c", "1", "-t", "10"]
13
cpu_shares: 512
14
15
stress3:
10 collapsed lines
16
image: stress:alpine
17
container_name: stress3
18
command: ["-c", "1", "-t", "10"]
19
cpu_shares: 768
20
21
stress4:
22
image: stress:alpine
23
container_name: stress4
24
command: ["-c", "1", "-t", "10"]
25
cpu_shares: 1024

執行結果:
在資源充足時,權重對容器的 CPU 使用沒有影響。

default


測試 2:資源不足時的權重分配

設定 WSL 資源限制

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

1
[wsl2]
2
memory=4GB
3
processors=2

重新啟動 WSL 後,資源限制生效。 default

再次執行 測試 1 中的 docker-compose.yml,觀察結果:

default


測試 3:默認 cpu-share

根據 Docker 官方文檔,未設置 cpu-shares 的容器將採用默認值 1024。我們來實驗看看。

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

docker-compose.yml
1
version: '2'
2
services:
3
stress:
4
image: stress:alpine
5
container_name: stress
6
command: ["-c", "1", "-t", "10"]
7
cpu_shares: 256
8
9
stress2:
10
image: stress:alpine
11
container_name: stress2
12
command: ["-c", "1", "-t", "10"]
13
cpu_shares: 512
14
15
stress3:
9 collapsed lines
16
image: stress:alpine
17
container_name: stress3
18
command: ["-c", "1", "-t", "10"]
19
cpu_shares: 768
20
21
stress4:
22
image: stress:alpine
23
container_name: stress4
24
command: ["-c", "1", "-t", "10"]

執行結果:
未設定 cpu-shares 的容器默認值為 1024,其分配的 CPU 資源比例高於其他容器。

default

觀察結果 從實驗結果來看,沒有設定 cpu-shares 的容器,在資源搶奪上的優先度是高於有設定 cpu-shares 容器。


結論

  • 在資源不足的情況下,容器的 CPU 分配比例由 cpu-shares 決定。權重較高的容器將獲得更多的 CPU 資源。
  • 未設定 cpu-shares 的容器,在資源搶奪上的優先度高於有設定 cpu-shares 容器。

實驗四:限制記憶體資源

記憶體資源限制是容器資源控制的重要部分。以下實驗將展示如何使用 Docker 限制容器記憶體,並觀察系統的行為。


測試 1:不限制記憶體

當未設定記憶體限制時,容器預設可以使用宿主機的所有可用記憶體。

測試指令:

Terminal window
1
docker container run -it --rm --name stress stress:memory

結果:

default


測試 2:限制記憶體使用量

將容器的記憶體限制為 300MB,觀察系統的行為。

測試指令:

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

結果:

default


觀察現象

  • 容器記憶體耗盡後,並未觸發 OOM(Out of Memory)Kill。
    相反,容器的 Swap 使用量開始上升。

分析

Swap 是 Linux 系統的虛擬記憶體,使用磁盤空間模擬記憶體,因此在記憶體耗盡時,會切換到使用 Swap。 當未限制容器的 Swap 使用量時,容器可以使用的 Swap 等於設置的記憶體限制(此例為 300MB)。

至於為什麼 Swap 使用量也耗盡後,為什麼還是沒觸發 OOM Kill 呢? 其實它已經發生了,下面我們來驗證。


測試 3:限制記憶體與 Swap 使用量

進一步限制容器的總記憶體(物理記憶體 + Swap)為 300MB,模擬更嚴格的限制條件。

系統監控

在宿主機中開啟新終端,執行以下指令監控 OOM 現象:

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

測試指令:

Terminal window
1
docker container run -it --rm --name stress --memory 300m --memory-swap 300m stress:memory
  • --memory 限制物理記憶體。
  • --memory-swap 限制總記憶體(物理記憶體 + Swap)。

結果:

default


觀察現象

  • 當容器記憶體與 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

memory_stress_test.py
1
import sys
2
import time
3
4
def trigger_oom():
5
# 創建一個巨大的列表來快速消耗記憶體
6
memory_list = []
7
current_size = 0
8
9
print("開始觸發 OOM...")
10
11
while True:
12
try:
13
# 每次分配約 100MB 的記憶體
14
chunk = ['x'] * (25 * 1024 * 1024)
15
memory_list.append(chunk)
11 collapsed lines
16
current_size += 100
17
18
print(f"當前已分配記憶體: {current_size} MB")
19
time.sleep(0.5)
20
21
except MemoryError:
22
print("記憶體分配失敗")
23
break
24
25
if __name__ == "__main__":
26
trigger_oom()

Dockerfile

Dockerfile.stress.memory.python
1
FROM python:3.9-slim
2
3
WORKDIR /app
4
5
COPY memory_stress_test.py .
6
7
CMD ["python", "memory_stress_test.py"]

構建指令:

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

測試指令

Terminal window
1
docker run -it --memory=500m stress:memory-py

結果:

default


確認 OOM 狀態

檢查容器的狀態:

Terminal window
1
docker container ls -a

輸出範例:

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

查看詳細資訊

使用 docker inspect 查看容器的結束狀態:

Terminal window
1
docker container inspect 5f3679d1c217 --format '{{json .State}}' | jq

結果範例:

1
{
2
"Status": "exited",
3
"Running": false,
4
"Paused": false,
5
"Restarting": false,
6
"OOMKilled": true,
7
"Dead": false,
8
"Pid": 0,
9
"ExitCode": 137,
10
"Error": "",
11
"StartedAt": "2024-12-10T11:20:50.956018772Z",
12
"FinishedAt": "2024-12-10T11:20:52.491126925Z"
13
}

解讀

  • ExitCode 137 表示容器因 OOM 而退出。
  • OOMKilledtrue,證明主進程觸發 OOM 導致容器被終止。

結論

通過上述實驗,我們證實了容器內的資源限制功能如何影響應用運行:

  1. CPU 資源限制:影響程式執行時間的等待部分,而非實際執行時間。
  2. 記憶體資源限制:觸發 OOM Kill 的條件與主進程和子進程的行為密切相關。
  3. Swap 的作用:在記憶體不足時,未限制的 Swap 會延緩 OOM 的發生。

希望這篇文章能幫助你更深入理解 Docker 的資源限制機制,並靈活應用於實際場景。

參考

本文標題:探索容器資源限制:透過實驗理解 Docker 的 CPU 和記憶體調度 (2)
文章作者:Vincent Lin
發布時間:2024-12-10