Skip to main content

3 posts tagged with "Docker"

View All Tags

Docker多階段構建

· 3 min read

從文章Docker鏡像分層與層共享中我們知道,Docker鏡像是由一層一層疊起來的,每一層對應Dockerfile中的一條指令。這些層都是唯讀的,構建完成後不可以修改。

問題

構建時候需要用到的東西,運行時候不需要,一個典型的python應用,構建需要

  • gcc/build-essential
  • pip
  • .h文件
  • 測試依賴

但是運行的時候只需要

  • python解釋器
  • 裝好的包
  • 你的代碼

先看沒有優化的鏡像有多大

假設現在有一個FastAPI應用:

myapp/
├── main.py
├── requirements.txt
└── Dockerfile

requirements

fastapi==0.110.0
uvicorn==0.29.0
pandas==2.2.0 #觸發了gcc的安裝
numpy==1.26.4
scikit-learn==1.4.0

最常見的寫法,能跑就行

FROM python:3.11

WORKDIR /app

COPY requirements.txt .

RUN pip install -r requirements.txt

COPY . .

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

這是本項目構建完後可能出現的大小

$ docker build -t myapp:naive .
$ docker images myapp:naive

REPOSITORY TAG IMAGE ID SIZE
myapp naive a1b2c3d4e5f6 1.08GB

1GB 多,原因:

python:3.11 基础镜像本身 900MB+ pip 安装时下载的 wheel 缓存没清 编译 numpy/pandas 留下的编译工具

pip的緩存是什麼

pip 安装包时,会先把 .whl 文件下载到本地缓存目录,装完之后缓存文件还留着,下次装同一个包可以不用重新下载。

安裝 numpy 的過程:

pip 下載 numpy-1.26.4-cp311-linux_x86_64.whl

存一份到緩存目錄 ~/.cache/pip/ ← 這份沒用了,但還占著空間

解壓安裝到 site-packages/ ← 這才是真正要用的

所以鏡像中存了兩份 numpy,一份是 pip 的緩存,一份是安裝好的包。

加入 --no-cache-dir 參數,pip 就不會把下載的包存到緩存目錄了:

RUN pip install --no-cache-dir -r requirements.txt

apt安裝包

安裝系統工具時,apt也會留下緩存

RUN apt-get update && \
apt-get install -y build-essential && \
rm -rf /var/lib/apt/lists/*

/var/lib/apt/lists/ 裡面存的是軟件包的索引列表,之後不會用到了。

為什麼分開RUN清理不掉,

RUN apt-get install -y gcc        # 第N層,安裝了gcc,留下了緩存
RUN rm -rf /var/lib/apt/lists/* # 第N+1層:只是標記刪除

在同一層產生,在同一層清理,才會真的消失。

即使用了緩存之後,鏡像大小可能並沒有降低多少。

$ docker images myapp:no-cache

REPOSITORY TAG SIZE
myapp no-cache 870MB

多階段構建

用一個階段來構建,安裝所有依賴,編譯好包,然後把需要的東西複製到另一個乾淨的階段,這樣就不會把構建過程中產生的垃圾帶到最終鏡像裡了。


FROM python:3.11 AS builder

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir \
--prefix=/install \
-r requirements.txt

FROM python:3.11-slim AS runtime

WORKDIR /app

COPY --from=builder /install /usr/local

COPY . .

RUN useradd -m appuser
USER appuser

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

如果現在構建:

$ docker build -t myapp:multistage .
$ docker images myapp

REPOSITORY TAG SIZE
myapp naive 1.08GB
myapp no-cache 870MB
myapp multistage 195MB

總結

多階段構建的本質是:

構建環境和運行環境分離。

Docker鏡像分層與層共享

· 3 min read

第一個問題

假設你的伺服器上跑了兩個容器:

container1: Ubuntu(200MB) + Nginx(50MB) = 250MB
container2: Ubuntu(200MB) + MySQL(100MB) = 300MB

按照常識來說,磁碟總共應該花了200+200+200+50+100=750MB,但是實際上只有200+50+100=350MB。這是為什麼呢?

因為Ubuntu那200MB的鏡像只存了一份,這就是Docker分層架構要做的事情。

Docker鏡像的本質:一疊唯讀的層

Docker鏡像不是一個整體的大檔案,而是一層一層疊起來的,每一層對應Dockerfile中的一條指令。

FROM ubuntu:22.04 # 這一層包含了ubuntu的檔案
RUN apt-get install -y nginx # 這一層包含了安裝nginx的檔案
RUN apt-get install -y mysql-server # 這一層包含了安裝mysql的檔案

每一層只記錄相對上一層的改變

┌─────────────────────┐
│ 第3層: config檔案 │ 1MB(只有這層的新內容)
├─────────────────────┤
│ 第2層: Nginx │ 30MB
├─────────────────────┤
│ 第1層: Ubuntu │ 200MB
└─────────────────────┘

所有層都是唯讀的,構建完成後不可以修改。

三:層共享

當你基於同一個 Ubuntu 構建兩個不同鏡像時:

image1 (nginx):   [Ubuntu 200MB] → [Nginx 50MB]
image2 (mysql): [Ubuntu 200MB] → [MySQL 100MB]

Docker 透過每層的 Content Hash(內容哈希)來識別層是否相同。Ubuntu 層的 hash 完全一致,所以磁碟上只存一份:

磁碟實際儲存:

┌─────────────────────┐
│ MySQL層 │ 100MB ← image2 獨有
├─────────────────────┤
│ Nginx層 │ 50MB ← image1 獨有
├─────────────────────┤
│ Ubuntu層 │ 200MB ← 兩個鏡像共享
└─────────────────────┘

總計:350MB,而不是 750MB

四:容器執行時

鏡像層是唯讀的,那容器如果在執行的時候寫檔案怎麼辦?

Docker在唯讀鏡像層上面再加了一層可寫的容器層

┌─────────────────────┐
│ 容器層(可寫) │ ← 容器執行時產生的檔案寫在這裡
├─────────────────────┤
│ Nginx層(唯讀) │
├─────────────────────┤
│ Ubuntu層(唯讀) │
└─────────────────────┘

這就是 Copy-on-Write(寫時複製)機制:

讀檔案:從上往下找,找到就返回
寫檔案:先把檔案從唯讀層複製到容器層,再修改

所以即使 100 個容器基於同一個鏡像,鏡像層依然只有一份,每個容器只多出自己那個很薄的容器層。

container1  container2  container3
[寫層1] [寫層2] [寫層3] ← 各自獨立,很小
↘ ↓ ↙
[共享的唯讀鏡像層] ← 只有一份

五:這對Dockerfile寫法的啟示

利用層快取:把不常變的指令放前面,常變的指令放後面,這樣修改後只需要重建後面的層,前面的層可以複用。

# ✅ 好的寫法
COPY requirements.txt . # 依賴檔案變化少 → 快取穩定
RUN pip install -r requirements.txt
COPY . . # 程式碼經常變 → 放最後

# ❌ 差的寫法
COPY . . # 程式碼一變,下面的依賴安裝就要重跑
RUN pip install -r requirements.txt

合併RUN減少層數

# ❌ 產生3層,中間層的快取檔案仍佔空間
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ 一層搞定,最終只保留有用的檔案
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*