想像你剛剛寫完了你的專案,並且想要將它打包成一個漂亮的 Docker 映像檔。
你大費周章的寫了一個 Dockerfile
,執行了 docker build
指令,過了 1 分鐘。當映像檔建置完成後,你發現這個映像檔超級大。就僅僅是一個簡單的 Node.js 程式,完整的容器映像檔竟然來到了快 1GB。
你可能會想問:「有沒有辦法減少映像檔的大小呢?」
古老的方法:單階段建置
在 Docker 首次推出時,建立一個 Docker 映像檔的唯一方式是用單一階段的建置。這是一個簡單且直接的方法。你只需寫一個簡單的 Dockerfile
,然後跑跑指令把它 build 起來。
以下是一個打包 C++ 應用程式的單階段 Dockerfile
範例:
FROM debian:12
COPY . /app
RUN apt-get update && apt-get install -y build-essential && \
gcc -o /app/donut /app/donut.c
ENTRYPOINT ["/app/donut"]
而 donut.c
是什麼呢?就是那個用 C 寫的,會轉圈圈的甜甜圈程式:
i,j,k,x,y,o,N;
main(){float z[1760],a
#define R(t,x,y) f=x;x-=t*y\
;y+=t*f;f=(3-x*x-y*y)/2;x*=f;y*=f;
=0,e=1,c=1,d=0,f,g,h,G,H,A,t,D;char
b[1760];for(;;){memset(b,32,1760);g=0,
h=1;memset(z,0,7040);for(j=0;j<90;j++){
G=0,H=1;for(i=0;i<314;i++){A=h+2,D=1/(G*
A*a+g*e+5);t=G*A *e-g*a;x=40+30*D
*(H*A*d-t*c);y= 12+15*D*(H*A*c+
t*d);o=x+80*y;N =8*((g*a-G*h*e)
*d-G*h*a-g*e-H*h *c);if(22>y&&y>
0&&x>0&&80>x&&D>z[o]){z[o]=D;b[o]=(N>0
?N:0)[".,-~:;=!*#$@"];}R(.02,H,G);}R(
.07,h,g);}for(k=0;1761>k;k++)putchar
(k%80?b[k]:10);R(.04,e,a);R(.02,d,
c);usleep(15000);printf('\n'+(
" donut.c! \x1b[23A"));}}
/*3D-spinning-donut */
參考
donut.c
:limiteci/donut.c
那個 Dockerfile
看起來很簡單,對吧?在打包過程中,我們安裝了 build-essential
套件,這個套件包含了你在編譯 C 應用程式時所需要的幾乎所有編譯器與工具。然後複製原始碼到容器裡面,編譯它,並且執行了編譯後的二進位檔。
不過這裡好像出了點問題:/app/donut
這個已編譯的程式不需要任何來自 build-essential
的工具,這些工具只在編譯程式時需要。所以,我們在最終映像檔中包含了不必要的工具。
移除不必要的小工具
為了解決這個問題,我們需要在編譯過程後移除不必要的工具。底下是一個簡單的範例:
FROM debian:12
COPY . /app
RUN apt-get update && apt-get install -y build-essential && \
gcc -o /app/donut /app/donut.c && \
apt remove -y build-essential && apt autoremove -y && apt-get clean
ENTRYPOINT ["/app/donut"]
在上面的範例中,我們在編譯過程後移除了 build-essential
套件,並且清理了 apt 快取,來減少映像檔的大小。
用 Alpine Linux 作為基底映像檔
這裡有另一個選擇,就是使用 Alpine Linux 作為基底映像檔。Alpine Linux 當初就是設計成極度輕量與安全。它只有 5MB 的大小(加上 busybox),比 Debian 小很多。
你可以用下面使用 Alpine Linux 的 Dockerfile
來取代上面的 Debian Dockerfile
:
FROM alpine:3
COPY . /app
RUN apk add --no-cache build-base && \
gcc -o /app/donut /app/donut.c && \
apk del build-base
ENTRYPOINT ["/app/donut"]
這裡的結果映像檔應該比 Debian 映像檔小很多,差不多是 5 倍的差距。
但是,這裡有個問題:Alpine Linux 使用 musl
作為標準 C 函式庫,這與大多數 Linux 發行版使用的 glibc
不同。這可能會導致一些 (通常不只一些) C/C++ 應用程式的相容性問題。
特別是如果你有一個來自不知道哪裡的的二進位檔,而且它又需要 glibc
才能執行,那麼祝你好運。嘻嘻
一個更好的方法:多階段建置
Docker 在 17.05 版本中引入了多階段建置。它允許你在多個階段中建置你的應用程式,並且將前面做出來的程式從一個階段丟到另一個階段。
我們可以這麼想:我們可以有一個大廚房,裡面擺滿了烹飪所需的工具和食材,而送給客戶的只是一個小盒子來盛裝食物。
以下是一個使用多階段建置的 Dockerfile
範例:
FROM debian:12 AS builder
RUN apt-get update && apt-get install -y build-essential
COPY . /app
RUN gcc -o /app/donut /app/donut.c
FROM debian:12
COPY --from=builder /app/donut /app/donut
ENTRYPOINT ["/app/donut"]
這個 Dockerfile
的作用是在第一階段建置應用程式,然後將編譯後的二進位檔複製到第二階段。第二階段是使用從乾淨的 debian:12
映想檔。這個 Dockerfile
建置出來的映像檔應該比不使用多階段的小,因為它不包含 build-essential
套件。
使用 distroless 映像檔
如果你有嘗試上面的範例,你可能會發現最終跑出來的映像檔還是很大。因為我們只移除了額外安裝的 build-essential
,基底映像檔 (Debian 的那個) 的大小還是很大。
2017 年,Google 推出了 distroless
映像檔,這些映像檔只包含執行應用程式所需必要的工具。也因為只包含最少的工具,它沒有多餘的東西。比純淨還要純淨。
也因為 distroless 映像檔沒有套件管理器,你基本上不能在映像檔中安裝額外的套件。你只能從前一階段複製檔案到這個階段。這也是為什麼多階段建置對於 distroless 映像檔來說很重要。
要烹飪一個 distroless 映像檔,我們需要使用 debian:12
當作第一階段,先建置二進位檔,然後將二進位檔複製到 gcr.io/distroless/static-debian12
映像檔:
FROM debian:12 AS builder
RUN apt-get update && apt-get install -y build-essential
COPY . /app
RUN gcc -static -o /app/donut /app/donut.c
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/donut /app/donut
ENTRYPOINT ["/app/donut"]
注意一下,這裡我們在
gcc
後面加上了-static
,如果你不加上這個參數,二進位檔會是動態連結的,這樣就需要glibc
才能執行。 (簡單來說,最終的映像檔會變大)或是你可以使用有
glibc
的gcr.io/distroless/base-debian12
映像檔
這個最終的映像檔應該比之前的 Debian 或 Alpine 映像檔小很多,因為它基本上只有一個 “donut” 的二進位檔而已。
最極致的解決方案
其實還有一個基底映像檔叫做 scratch
。
哦,不是,我不是在講那隻叫做 Scratch 的橘色福瑞貓
scratch
其實是一個很特別的映像檔,老實說他其實只是一個特殊的名字,Docker 會把它視為一個 “建立一個新的空白檔案系統” 的指令。
畢竟是一個空白的檔案系統,它當然也沒有任何系統的函式庫等等的工具,這個讓它變得超級小及安全。
我們可以用這個簡單的範例來看看怎麼用 scratch
映像檔:
FROM debian:12 AS builder
RUN apt-get update && apt-get install -y build-essential
COPY . /app
RUN gcc -static -o /app/donut /app/donut.c
FROM scratch
COPY --from=builder /app/donut /app/donut
ENTRYPOINT ["/app/donut"]
使用
scratch
的映像檔必須為靜態連結的二進位檔案,你不能用動態連結的二進位檔,因為scratch
裡面根本沒有東西可以動態連結到。
這個 Dockerfile
應該會跑出最小的 docker 映像檔,因為它裡面只有一個 donut
執行檔而已。
最終比較
基底映像檔 | 整體大小 |
---|---|
Scratch | 767kB |
Distroless + 多階段建置 | 2.76MB |
Alpine | 7.4MB |
Debian,使用多階段建置 | 117MB |
Debian,單階段,移除 build-essential | 140MB |
Debian,單階段,不移除 build-essential | 534MB |
以上大小僅供參考,實際大小可能會有所不同。這些映像檔是在我的筆電上使用 Docker
28.0.1
建置的。
以上數據顯示,scratch 得到了最小的映像檔大小。distroless 排在第二,而 Alpine 則是第三。
雖然在這個比較中,Alpine 和 distroless 沒有跑出最小的映像檔,但考慮到它只有幾 MB 的大小,它仍然是一個不錯的選擇。
結論
所以你應該選擇什麼呢?Scratch 還是 distroless 還是 Alpine 還是 Debian?
我的建議是,如果你的程式不需要任何一般 Linux 系統會有的函式庫,請考慮使用 scratch
。
而如果 Google 已經很好心的幫你做了一個包含你要使用的執行環境的 distroless base image,那麼請使用 distroless。目前,distroless 有 python3
、java-{17, 21}
和 nodejs-{18, 20, 22}
。這些映像檔包含了這些執行環境,而且應該是經過特別優化的。
至於 Alpine,如果你的應用程式用了一些特別的執行環境,而且恰恰好做那個環境的人有提供已經建立好的 Alpine 映像檔,那麼請使用它。
至於 Debian 呢?除非你有一個非常特殊的使用情況,否則我建議不要使用它作為基底映像檔。