優化 Python Docker Image Size - 從 multi-stage builds 到 distroless

Posted on  Jan 6, 2023  in  Docker , Python 程式設計 - 高階  by  Amo Chen  ‐ 5 min read

Docker multi-stage builds 教學 一文介紹以 Golang 作為範例,示範如何用 Docker multi-stage builds 的功能,優化編譯 Docker image 的過程,以減少 Docker Image 的 size 。

Multi-stage builds 並不局限於 Golang 這類的編譯(compiled)語言才能使用,腳本(script)語言也能夠運用類似的技巧降低 Docker image size, 例如 Javascript, Python 等開發生態系也都能夠使用。

只是腳本語言需透過直譯器(interpreter)執行的天性,因此其 Docker image 終究難以像 Golang 這類編譯語言所產生的 image 來得小,但這並不代表 Python, Javascript 這類的 Docker image 並不值得使用 multi-stage builds, 優化 Docker image size 仍可以為部署(deployment)速度帶來優勢,同時也能減少網路傳輸所需付出的費用成本。

本文的 multi-stage builds 以 Python 範例出發,一路介紹到如何使用 Google 所提供 distroless 進一步優化 Docker image size 與安全性。

本文環境

Python multi-stage builds template

以下提供常見的 Python 專案的 Dockerfile 樣板範例,可以視情況修改為符合自己需求的版本:

# temp stage
FROM python:3.9-slim as builder

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

COPY requirements.txt .
RUN apt-get update && apt-get install ...(略)... && \
    pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt

# final stage
FROM python:3.9-slim

WORKDIR /app

COPY --from=builder /app/wheels /wheels
COPY --from=builder /app/requirements.txt .

RUN pip install --no-cache /wheels/*
ENTRYPOINT ["...(略)..."]

Docker Multi-stage Builds - Python 範例

以下是 Python 的 multi-stage builds 的 Dockerfile 範例,該範例在 builder 階段安裝 Jupyter 以及 pandas 套件,並將安裝的套件打包(packaging)成 wheel 檔案並儲存到路徑 /wheels 底下。

參數 --no-cache-dir 會讓 pip 不要使用快取(cache),因此每次安裝時都會從網路重新下載套件。

參數 --no-deps 則會告訴 pip 不要安裝相依套件(dependencies)。相依的套件我們會在最後階段(final stage)才進行安裝。

參數 --wheel-dir 則會指定套件安裝後打包成 wheel 檔案的儲存路徑位置。

Wheel is a built-package format, and offers the advantage of not recompiling your software during every install.

使用 wheel 檔案可以讓最後階段的 Docker image 只要直接複製過去安裝即可,不需要再重新編譯(compile),可以優化編譯速度。

# temp stage
FROM python:3.9 as builder

ENV PYTHONDONTWRITEBYTECODE 1
RUN pip wheel --no-cache-dir --no-deps \
    --wheel-dir /wheels jupyter pandas

# final stage
FROM python:3.9-slim

COPY --from=builder /wheels /wheels
RUN pip install --no-cache /wheels/* && addgroup --system app && adduser --system --group app
USER app
WORKDIR /notebooks
ENTRYPOINT ["jupyter", "notebook", "--ip=0.0.0.0", "--port=8888", "--no-browser"]

上述範例 ENV PYTHONDONTWRITEBYTECODE 1 則是讓 Python 不要產生 .pyc 檔,多多少少可以減少一些空間。

前述 Dockerfile 可用以下指令進行編譯:

$ docker build --no-cache -t jupyter-multi -f <path to Dockerfile>

如此即可完成 Python 版本的 Docker multi-stage builds 。

對比使用沒有 Docker multi-stage builds 的 Docker Image

為了對比 multi-stage builds 到底有沒有優化 image size, 以下是沒有使用 Docker multi-stage builds 的 Dockerfile 範例,同樣僅安裝 Jupyter 以及 pandas 套件:

FROM python:3.9-slim

RUN pip install --no-cache jupyter pandas && addgroup --system app && adduser --system --group app
USER app
WORKDIR /notebooks
ENTRYPOINT ["jupyter", "notebook", "--ip=0.0.0.0", "--port=8888", "--no-browser"]

前述 Dockerfile 可用以下指令進行編譯:

$ docker build --no-cache -t jupyter-single  -f <path to Dockerfile>

編譯完成之後,可以使用 docker images 指令查看 image size:

$ docker images
REPOSITORY       TAG       IMAGE ID       CREATED          SIZE
jupyter-multi    latest    58fa0629888f   24 seconds ago   390MB
jupyter-single   latest    22b722bbc07f   2 minutes ago    377MB

從上述結果可以發現 2 者的 image size 幾乎沒有差異,令人驚訝的是,甚至不使用 multi-stage builds 反而還比較省空間。

這是為什麼呢?

原因在於兩者的安裝過程都是使用 wheel 版本的套件,省略編譯(compile)過程,所以安裝完成之後檔案大小並不會改變,反而 multi stage build 相較沒有使用 multi-stage builds 的版本多了一個 COPY 指令,從而導致 image size 反而增加 12MB 。

可以使用 docker image history 指令查看各個 image layer 佔用多少空間,可以從結果看到 COPY 指令多了 12MB :

$ docker image history jupyter-multi

小結

那麼,前述的實驗是否代表 Python 不需要使用 multi-stage builds?

答案是: 視情況而定,要實際試過才知道

本文所提供的範例僅是簡單安裝 2 個套件進行比較,這些套件也都已提供 wheel 版本給與 Linux 的使用者進行安裝,因此省略了許多編譯過程,通常編譯套件的過程通常需要安裝各式各樣額外的工具或指令,使得 Docker image size 在此階段變得龐大,也由於 wheel 版本的套件讓我們省略編譯的過程,使得有沒有使用 multi-stage builds 都可以節省安裝各式各樣額外的工具或指令的步驟,從而讓 Docker image size 不會增加太多,這也是為何本文範例有沒有使用 multi-stage builds 的差異很小的原因。

不過現代的應用(application)通常不會只有安裝簡單幾個套件,也不會所有套件都有提供 wheel 版本供我們安裝, multi-stage build 仍可以幫助開發者隔離(isolate)編譯階段,讓開發者可以只將編譯後的結果複製到最終的 Docker image 即可,幫助優化 Docker image size 。

如果你的 Python 專案都只使用 wheel 版本的套件,也不需額外安裝或編譯其他工具/指令的情況,那麼不使用 multi-stage builds 是沒有任何問題的。

Alpine vs. Slim

目前 Python 的 Docker image 提供 slim 與 alpine 這 2 種相對較小的 image 可供使用,不過 alpine 是極簡化的 image, 少裝了很多指令與編譯工具,甚至是使用輕量化的 C 函式庫(musl libc) ,通常不建議開發初期就使用 alpine 做為 base image, 因為可能會花費不少時間在滿足各種編譯、相依性的要求,甚至某些套件可能會無法正常運作。

建議可以先使用 slim 作為 base image 。

Distroless

如果已經使用 multi-stage builds 將 Docker image 優化至極限,但是又不想觸碰 alpine 這個坑呢?

也許可以考慮使用 Google 所提供的distroless 作為 base image 。

“Distroless” images contain only your application and its runtime dependencies. They do not contain package managers, shells or any other programs you would expect to find in a standard Linux distribution.

Distroless 只提供 application 以及其執行所需要的環境,拿掉了 apt, pip, npm 等 package manager 之外,也拿掉很多不必要的指令/工具(包括 sh, bash 等 shell),不僅減少 image size 也提升安全性(因為沒有 shell 可用就提升不少作惡難度)。

目前 Distroless 有以下 4 種 tag 可用:

  • latest
  • nonroot
  • debug
  • debug-nonroot

如果要 debug 可以使用 debugdebug-nonroot ,此 2 個 tag 有 shell 可以使用。

使用 distroless 只要將 FROM 後的 base image 改為 FROM gcr.io/distroless/<image>:<tag>

以 Python 為例就是改為 FROM gcr.io/distroless/python3-debian11:latest

以下是完整 Dockerfile, 將前面章節的 multi-stage 範例改為使用 distroless:

# temp stage
FROM python:3.9 as builder

ENV PYTHONDONTWRITEBYTECODE 1
RUN pip install jupyter pandas

# final stage
FROM gcr.io/distroless/python3-debian11:nonroot

COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/dist-packages
COPY --from=builder /usr/local/bin/jupyter* /usr/local/bin/
WORKDIR /notebooks
CMD ["/usr/local/bin/jupyter-notebook", "--ip=0.0.0.0", "--no-browser"]

上述範例值得注意的是:

由於 distroless 不提供 pip 等 package manager, 所以我們在編譯階段以 pip 安裝所有需要的套件,也就是 RUN pip install jupyter pandas 的部分。

接著,在最後階段將 Python 整個 site-packages/ 從 builder 複製到最後的 image 內,複製所有需要的套件,也就是 COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/dist-packages 的部分。

p.s. 如果想知道 /usr/local/lib/python3.9/dist-packages 如何得到,可以用 debug tag 進 container 執行 python3 -m site 指令取得

上述 Dockerfile 可以用以下指令編譯:

$ docker build --no-cache -t jupyter-distroless  -f <path to Dockerfile>

編譯完成後,可以比較看看 docker image size:

$ docker images
REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
jupyter-distroless   latest    b57fbdacd9cd   11 seconds ago   305MB
jupyter-multi        latest    58fa0629888f   2 hours ago      390MB
jupyter-single       latest    22b722bbc07f   2 hours ago      377MB

上述結果可以看到改用 distroless 之後, Docker image size 從 377MB 減少到 305MB, 進一步減少了約 20% 。

截至目前為止, distroless 並沒有使用任何 Python 以外的指令或編譯工具,如果要在 distroless 上使用 Python 以外的指令或者編譯工具,就會相對不方便,得去查閱相關文件以了解如何將之從 build stage 複製到最後階段。

總結

本文展示 Python 的 multi-stage builds 過程,以及從一個簡單的實驗探討 multi-stage builds 可能結果沒有太大差異的問題,最後學習如何用 distroless 再次優化 docker image size 。

總的而言,最好是一開始就先使用 slim 作為 base image, 如果 image size 已經成為部署或成本上的痛點時,再來優化 docker image size 才會比較省心。

以上!

Happy Coding!

References

GitHub - GoogleContainerTools/distroless: 🥑 Language focused docker images, minus the operating system.

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!