簡介:?本文主要講解針對不同的語言來選擇適當的精簡策略,其中主要討論?Go,同時也涉及到了?Java,Node,Python,Ruby和?Rust。同時也會詳細介紹 Alpine 鏡像的避坑指南。
一、Go 語言鏡像精簡
?
Go?語言程序編譯時會將所有必須的依賴編譯到二進制文件中,但也不能完全肯定它使用的是靜態鏈接,因為?Go?的某些包是依賴系統標準庫的,例如使用到 DNS 解析的包。只要代碼中導入了這些包,編譯的二進制文件就需要調用到某些系統庫,為了這個需求,Go 實現了一種機制叫?cgo,以允許 Go 調用 C 代碼,這樣編譯好的二進制文件就可以調用系統庫。
也就是說,如果 Go 程序使用了?net?包,就會生成一個動態的二進制文件,如果想讓鏡像能夠正常工作,必須將需要的庫文件復制到鏡像中,或者直接使用?busybox:glibc?鏡像。
當然,你也可以禁止?cgo,這樣 Go 就不會使用系統庫,使用內置的實現來替代系統庫(例如使用內置的 DNS 解析器),這種情況下生成的二進制文件就是靜態的。可以通過設置環境變量?CGO_ENABLED=0?來禁用 cgo,例如:
FROM golang
COPY whatsmyip.go .ENV CGO_ENABLED=0RUN go build whatsmyip.go
FROM scratch
COPY --from=0 /go/whatsmyip .CMD ["./whatsmyip"]
?
由于編譯生成的是靜態二進制文件,因此可以直接跑在?scratch?鏡像中 ?
當然,也可以不用完全禁用?cgo,可以通過?-tags?參數指定需要使用的內建庫,例如?-tags netgo?就表示使用內建的 net 包,不依賴系統庫:
$ go build -tags netgo whatsmyip.go
?
這樣指定之后,如果導入的其他包都沒有用到系統庫,那么編譯得到的就是靜態二進制文件。也就是說,只要還有一個包用到了系統庫,都會開啟?cgo,最后得到的就是動態二進制文件。要想一勞永逸,還是設置環境變量?CGO_ENABLED=0?吧。
二、Alpine 鏡像探秘
?
上篇文章已經對?Alpine?鏡像作了簡要的介紹,并保證會在后面的文章中花很大的篇幅來討論?Alpine?鏡像,現在時候到了!
Alpine?是眾多 Linux 發行版中的一員,和?CentOS、Ubuntu、Archlinux?之類一樣,只是一個發行版的名字,號稱小巧安全,有自己的包管理工具?apk。
與 CentOS 和 Ubuntu 不同,Alpine 并沒有像?Red Hat?或?Canonical?之類的大公司為其提供維護支持,軟件包的數量也比這些發行版少很多(如果只看開箱即用的默認軟件倉庫,Alpine 只有?10000?個軟件包,而 Ubuntu、Debian 和 Fedora 的軟件包數量均大于?50000。)
容器崛起之前,Alpine?還是個無名之輩,可能是因為大家并不是很關心操作系統本身的大小,畢竟大家只關心業務數據和文檔,程序、庫文件和系統本身的大小通常可以忽略不計。
容器技術席卷整個軟件產業之后,大家都注意到了一個問題,那就是容器的鏡像太大了,浪費磁盤空間,拉取鏡像的時間也很長。于是,人們開始尋求適用于容器的更小的鏡像。對于那些耳熟能詳的發行版(例如 Ubuntu、Debian、Fedora)來說,只能通過刪除某些工具(例如?ifconfig?和?netstat)將鏡像體積控制在?100M?以下。而對于 Alpine 而言,什么都不用刪除,鏡像大小也就只有?5M?而已。
Alpine 鏡像的另一個優勢是包管理工具的執行速度非常快,安裝軟件體驗非常順滑。誠然,在傳統的虛擬機上不需要太關心軟件包的安裝速度,同一個包只需要裝一次即可,無需不停重復安裝。容器就不一樣了,你可能會定期構建新鏡像,也可能會在運行的容器中臨時安裝某些調試工具,如果軟件包的安裝速度很慢,會很快消磨掉我們的耐心。
為了更直觀,我們來做個簡單的對比測試,看看不同的發行版安裝?tcpdump?需要多長時間,測試命令如下:
? → time docker run install tcpdump
測試結果如下:
Base image Size Time to install tcpdump
---------------------------------------------------------
alpine:3.11 5.6 MB 1-2s
archlinux:20200106 409 MB 7-9s
centos:8 237 MB 5-6s
debian:10 114 MB 5-7s
fedora:31 194 MB 35-60s
ubuntu:18.04 64 MB 6-8s
?
如果你想了解更多關于 Alpine 的內幕,可以看看?Natanel Copa 的演講。
好吧,既然 Alpine 這么棒,為什么不用它作為所有鏡像的基礎鏡像呢?別急,先一步一步來,為了趟平所有的坑,需要分兩種情況來考慮:
使用 Alpine 作為第二構建階段(run?階段)的基礎鏡像
使用 ALpine 作為所有構建階段(run?階段和?build?階段)的基礎鏡像
run 階段使用 Alpine
帶著激動的心情,將 Alpine 鏡像加入了 Dockerfile:
FROM gcc AS mybuildstage
COPY hello.c .RUN gcc -o hello hello.c
FROM alpine
COPY --from=mybuildstage hello .CMD ["./hello"]
第一個坑來了,啟動容器出現了錯誤:
standard_init_linux.go:211: exec user process caused "no such file or directory"
?
這個報錯在上篇文章已經見識過了,上篇文章的場景是使用?scratch?鏡像作為?C?語言程序的基礎鏡像,錯誤的原因是?scratch?鏡像中缺少動態庫文件。可是為什么使用 Alpine 鏡像也有報錯,難道它也缺少動態庫文件?
也不完全是,Alpine 使用的也是動態庫,畢竟它的設計目標之一就是占用更少的空間。但 Alpine 使用的標準庫與大多數發行版不同,它使用的是?musl libc,這個庫相比于?glibc?更小、更簡單、更安全,但是與大家常用的標準庫?glibc?并不兼容。
你可能又要問了:『既然?musl libc?更小、更簡單,還特么更安全,為啥其他發行版還在用?glibc?』
mmm。。。因為?glibc?有很多額外的擴展,并且很多程序都用到了這些擴展,而?musl libc?是不包含這些擴展的。詳情可以參考?musl 的文檔。
也就是說,如果想讓程序跑在 Alpine 鏡像中,必須在編譯時使用?musl libc?作為動態庫。
所有階段使用 Alpine
為了生成一個與?musl libc?鏈接的二進制文件,有兩條路:
某些官方鏡像提供了 Alpine 版本,可以直接拿來用。
還有些官方鏡像沒有提供 Alpine 版本,我們需要自己構建。
?
golang 鏡像就屬于第一種情況,golang:alpine?提供了基于 Alpine 構建的?Go?工具鏈。
構建 Go 程序可以使用下面的?Dockerfile:
FROM golang:alpine
COPY hello.go .RUN go build hello.go
FROM alpine
COPY --from=0 /go/hello .CMD ["./hello"]
生成的鏡像大小為 7.5M,對于一個只打印 『hello world』的程序來說確實有點大了,但我們可以換個角度:
即使程序很復雜,生成的鏡像也不會很大。
包含了很多有用的調試工具。
即使運行時缺少某些特殊的調試工具,也可以迅速安裝。
Go 語言搞定了,C 語言呢?并沒有?gcc:alpine?這樣的鏡像啊。只能以 Alpine 鏡像作為基礎鏡像,自己安裝 C 編譯器了,Dockerfile 如下:
FROM alpine
RUN apk add build-baseCOPY hello.c .RUN gcc -o hello hello.c
FROM alpine
COPY --from=0 hello .CMD ["./hello"]
必須安裝?build-base,如果安裝?gcc,就只有編譯器,沒有標準庫。build-base 相當于 Ubuntu 的?build-essentials,引入了編譯器、標準庫和 make 之類的工具。
最后來對比一下不同構建方法得到的 『hello world』鏡像大小:
使用基礎鏡像?golang?構建:805MB
多階段構建,build 階段使用基礎鏡像?golang,run 階段使用基礎鏡像?ubuntu:66.2MB
多階段構建,build 階段使用基礎鏡像?golang:alpine,run 階段使用基礎鏡像?alpine:7.6MB
多階段構建,build 階段使用基礎鏡像?golang,run 階段使用基礎鏡像?scratch:2MB
最終鏡像體積減少了?99.75%,相當驚人了。再來看一個更實際的例子,上一節提到的使用?net?的程序,最終的鏡像大小對比:
使用基礎鏡像?golang?構建:810MB
多階段構建,build 階段使用基礎鏡像?golang,run 階段使用基礎鏡像?ubuntu:71.2MB
多階段構建,build 階段使用基礎鏡像?golang:alpine,run 階段使用基礎鏡像?alpine:12.6MB
多階段構建,build 階段使用基礎鏡像?golang,run 階段使用基礎鏡像?busybox:glibc:12.2MB
多階段構建,build 階段使用基礎鏡像?golang?并使用參數 CGO_ENABLED=0,run 階段使用基礎鏡像?ubuntu:7MB
?
鏡像體積仍然減少了?99%。
三、Java 語言鏡像精簡
?
Java?屬于編譯型語言,但運行時還是要跑在?JVM?中。那么對于 Java 語言來說,該如何使用多階段構建呢?
靜態還是動態?
?
從概念上來看,Java 使用的是動態鏈接,因為 Java 代碼需要調用?JVM?提供的?Java API,這些 API 的代碼都在可執行文件之外,通常是?JAR?文件或?WAR?文件。
然而這些 Java 庫并不是完全獨立于系統庫的,某些 Java 函數最終還是會調用系統庫,例如打開文件時需要調用?open(),?fopen()?或它們的變體,因此?JVM?本身可能會與系統庫動態鏈接。
這就意味著理論上可以使用任意的?JVM?來運行 Java 程序,系統標準庫是?musl libc?還是?glibc?都無所謂。因此,也就可以使用任意帶有?JVM?的基礎鏡像來構建 Java 程序,也可以使用任意帶有?JVM?的鏡像作為運行 Java 程序的基礎鏡像。
類文件格式
?
Java 類文件(Java 編譯器生成的字節碼)的格式會隨著版本而變化,且大部分變化都是?Java API?的變化。還有一部分更改與 Java 語言本身有關,例如?Java 5?中添加了泛型,這種變化就可能會導致類文件格式的變化,從而破壞與舊版本的兼容性。
所以默認情況下,使用給定版本的 Java 編譯器編譯的類不能與更早版本的 JVM 兼容,但可以指定編譯器的?-target?(Java 8 及其以下版本)參數或者?--release?(Java 9 及其以上版本)參數來使用較舊的類文件格式。--release?參數還可以指定類文件的路徑,以確保程序運行在指定的 JVM 版本中(例如 Java 11),不會意外調用 Java 12 的 API。
JDK vs JRE
?
如果你對大多數平臺上的 Java 打包方式很熟悉,那你應該知道?JDK?和?JRE。
JRE?即 Java 運行時環境(Java Runtime Environment),包含了運行 Java 程序所需要的環境,即?JVM。
JDK?即 Java 開發工具包(Java Development Kit),既包含了 JRE,也包含了開發 Java 程序所需的工具,即 Java 編譯器。
大多數 Java 鏡像都提供了 JDK 和 JRE 兩種標簽,因此可以在多階段構建的?build?階段使用?JDK?作為基礎鏡像,run?階段使用?JRE?作為基礎鏡像。
Java vs OpenJDK
?
推薦使用?openjdk,因為開源啊,更新勤快啊~~
也可以使用?amazoncorretto,這是?Amazon?fork OpenJDK 后打了補丁的版本,號稱企業級。
開始構建
說了那么多,到底該用哪個鏡像呢?這里給出幾個參考:
openjdk:8-jre-alpine(85MB)
openjdk:11-jre(267MB)或者?openjdk:11-jre-slim(204MB)
openjdk:14-alpine(338MB)
如果你想要更直觀的數據,可以看我的例子,還是搬出屢試不爽的 『hello world』,只不過這次是 Java 版本:
class hello {
public static void main(String [] args) {
System.out.println("Hello, world!");
}
}
不同構建方法得到的鏡像大小:
使用基礎鏡像?java?構建:643MB
使用基礎鏡像?openjdk?構建:490MB
多階段構建,build 階段使用基礎鏡像?openjdk,run 階段使用基礎鏡像?openjdk:jre:479MB
使用基礎鏡像?amazoncorretto?構建:390MB
多階段構建,build 階段使用基礎鏡像?openjdk:11,run 階段使用基礎鏡像?openjdk:11-jre:267MB
多階段構建,build 階段使用基礎鏡像?openjdk:8,run 階段使用基礎鏡像?openjdk:8-jre-alpine:85MB
?
所有的 Dockerfile 都可以在這個倉庫找到。
四、解釋型語言鏡像精簡
?
對于諸如?Node、Python、Rust?之類的解釋型語言來說,情況就比較復雜一點了。先來看看 Alpine 鏡像。
Alpine 鏡像
對于解釋型語言來說,如果程序僅用到了標準庫或者依賴項和程序本身使用的是同一種語言,且無需調用 C 庫和外部依賴,那么使用?Alpine?作為基礎鏡像一般是沒有啥問題的。一旦你的程序需要調用外部依賴,情況就復雜了,想繼續使用 Alpine 鏡像,就得安裝這些依賴。根據難度可以劃分為三個等級:
簡單:依賴庫有針對 Alpine 的安裝說明,一般會說明需要安裝哪些軟件包以及如何建立依賴關系。但這種情況非常罕見,原因前面也提到了,Alpine 的軟件包數量比大多數流行的發行版要少得多。
中等:依賴庫沒有針對 Alpine 的安裝說明,但有針對別的發行版的安裝說明。我們可以通過對比找到與別的發行版的軟件包相匹配的 Alpine 軟件包(假如有的話)。
困難:依賴庫沒有針對 Alpine 的安裝說明,但有針對別的發行版的安裝說明,但是 Alpine 也沒有與之對應的軟件包。這種情況就必須從源碼開始構建!
?
最后一種情況最不推薦使用 Alpine 作為基礎鏡像,不但不能減小體積,可能還會適得其反,因為你需要安裝編譯器、依賴庫、頭文件等等。。。更重要的是,構建時間會很長,效率低下。如果非要考慮多階段構建,就更復雜了,你得搞清楚如何將所有的依賴編譯成二進制文件,想想就頭大。因此一般不推薦在解釋型語言中使用多階段構建。
有一種特殊情況會同時遇到 Alpine 的絕大多數問題:將 Python 用于數據科學。numpy?和?pandas?之類的包都被預編譯成了?wheel,wheel?是 Python 新的打包格式,被編譯成了二進制,用于替代 Python 傳統的?egg?文件,可以通過?pip?直接安裝。但這些 wheel 都綁定了特定的 C 庫,這就意味著在大多數使用?glibc?的鏡像中都可以正常安裝,但 Alpine 鏡像就不行,原因你懂得,前面已經說過了。如果非要在 Alpine 中安裝,你需要安裝很多依賴,重頭構建,耗時又費力,有一篇文章專門解釋了這個問題:使用 Alpine 構建 Pyhton 鏡像會將構建速度拖慢 50 倍!。
既然 Alpine 鏡像這么坑,那么是不是只要是 Python 寫的程序就不推薦使用 Alpine 鏡像來構建呢?也不能完全這么肯定,至少 Python 用于數據科學時不推薦使用 Alpine,其他情況還是要具體情況具體分析,如果有可能,還是可以試一試 Alpine 的。
:slim 鏡像
?
如果實在不想折騰,可以選擇一個折衷的鏡像?xxx:slim。slim 鏡像一般都基于?Debian?和?glibc,刪除了許多非必需的軟件包,優化了體積。如果構建過程中需要編譯器,那么 slim 鏡像不適合,除此之外大多數情況下還是可以使用 slim 作為基礎鏡像的。
下面是主流的解釋型語言的 Alpine 鏡像和 slim 鏡像大小對比:
Image Size
---------------------------
node 939 MB
node:alpine 113 MB
node:slim 163 MB
python 932 MB
python:alpine 110 MB
python:slim 193 MB
ruby 842 MB
ruby:alpine 54 MB
ruby:slim 149 MB
再來舉個特殊情況的例子,同時安裝?matplotlib,numpy?和?pandas,不同的基礎鏡像構建的鏡像大小如下:
Image and technique Size
--------------------------------------
python 1.26 GB
python:slim 407 MB
python:alpine 523 MB
python:alpine multi-stage 517 MB
?
可以看到這種情況下使用 Alpine 并沒有任何幫助,即使使用多階段構建也無濟于事。
但也不能全盤否定 Alpine,比如下面這種情況:包含大量依賴的?Django?應用。
Image and technique Size
--------------------------------------
python 1.23 GB
python:alpine 636 MB
python:alpine multi-stage 391 MB
?
最后來總結一下:到底使用哪個基礎鏡像并不能蓋棺定論,有時使用 Alpine 效果更好,有時反而使用 slim 效果更好,如果你對鏡像體積有著極致的追求,可以這兩種鏡像都嘗試一下。相信隨著時間的推移,我們就會積累足夠的經驗,知道哪種情況該用 Alpine,哪種情況該用 slim,不用再一個一個嘗試。
五、Rust 語言鏡像精簡
?
Rust?是最初由?Mozilla?設計的現代編程語言,并且在?Web?和基礎架構領域中越來越受歡迎。Rust 編譯的二進制文件動態鏈接到 C 庫,可以正常運行于?Ubuntu、Debian?和?Fedora?之類的鏡像中,但不能運行于?busybox:glibc?中。因為 Rust 二進制需要調用?libdl?庫,busybox:glibc?中不包含該庫。
還有一個?rust:alpine?鏡像,Rust 編譯的二進制也可以正常運行其中。
如果考慮編譯成靜態鏈接,可以參考?Rust 官方文檔。在 Linux 上需要構建一個特殊版本的 Rust 編譯器,構建的依賴庫就是?musl libc,你沒有看錯,就是 Alpine 中的那個?musl libc。如果你想獲得更小的鏡像,請按照文檔中的說明進行操作,最后將生成的二進制文件扔進?scratch?鏡像中就好了。
審核編輯:湯梓紅
?
評論
查看更多