Docker 映象製作教程:針對不同語言的精簡策略

xiaolongli發表於2021-12-21

本系列文章將分為三個部分:

第一部分著重介紹多階段構建(multi-stage builds),因為這是映象精簡之路至關重要的一環。在這部分內容中,我會解釋靜態連結和動態連結的區別,它們對映象帶來的影響,以及如何避免那些不好的影響。中間會穿插一部分對 Alpine 映象的介紹。連結:Docker 映象製作教程:減小映象體積

第二部分將會針對不同的語言來選擇適當的精簡策略,其中主要討論 Go,同時也涉及到了 JavaNodePythonRubyRust。這一部分也會詳細介紹 Alpine 映象的避坑指南。什麼?你不知道 Alpine 映象有哪些坑?我來告訴你。連結:Docker 映象製作教程:針對不同語言的精簡策略

第三部分將會探討適用於大多數語言和框架的通用精簡策略,例如使用常見的基礎映象、提取可執行檔案和減小每一層的體積。同時還會介紹一些更加奇特或激進的工具,例如 BazelDistrolessDockerSlimUPX,雖然這些工具在某些特定場景下能帶來奇效,但大多情況下會起到反作用。

本文介紹第二部分。

1. 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=0
RUN 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 吧。

2. Alpine 映象探祕

上篇文章已經對 Alpine 映象作了簡要的介紹,並保證會在後面的文章中花很大的篇幅來討論 Alpine 映象,現在時候到了!

Alpine 是眾多 Linux 發行版中的一員,和 CentOSUbuntuArchlinux 之類一樣,只是一個發行版的名字,號稱小巧安全,有自己的包管理工具 apk

與 CentOS 和 Ubuntu 不同,Alpine 並沒有像 Red HatCanonical 之類的大公司為其提供維護支援,軟體包的數量也比這些發行版少很多(如果只看開箱即用的預設軟體倉庫,Alpine 只有 10000 個軟體包,而 Ubuntu、Debian 和 Fedora 的軟體包數量均大於 50000。)

容器崛起之前,Alpine 還是個無名之輩,可能是因為大家並不是很關心作業系統本身的大小,畢竟大家只關心業務資料和文件,程式、庫檔案和系統本身的大小通常可以忽略不計。

容器技術席捲整個軟體產業之後,大家都注意到了一個問題,那就是容器的映象太大了,浪費磁碟空間,拉取映象的時間也很長。於是,人們開始尋求適用於容器的更小的映象。對於那些耳熟能詳的發行版(例如 Ubuntu、Debian、Fedora)來說,只能通過刪除某些工具(例如 ifconfignetstat)將映象體積控制在 100M 以下。而對於 Alpine 而言,什麼都不用刪除,映象大小也就只有 5M 而已。

Alpine 映象的另一個優勢是包管理工具的執行速度非常快,安裝軟體體驗非常順滑。誠然,在傳統的虛擬機器上不需要太關心軟體包的安裝速度,同一個包只需要裝一次即可,無需不停重複安裝。容器就不一樣了,你可能會定期構建新映象,也可能會在執行的容器中臨時安裝某些除錯工具,如果軟體包的安裝速度很慢,會很快消磨掉我們的耐心。

為了更直觀,我們來做個簡單的對比測試,看看不同的發行版安裝 tcpdump 需要多長時間,測試命令如下:

? → time docker run <image> <packagemanager> 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 這麼棒,為什麼不用它作為所有映象的基礎映象呢?別急,先一步一步來,為了趟平所有的坑,需要分兩種情況來考慮:

  1. 使用 Alpine 作為第二構建階段(run 階段)的基礎映象
  2. 使用 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-base
COPY 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%

3. 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 打包方式很熟悉,那你應該知道 JDKJRE

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 都可以在這個倉庫找到。

4. 解釋型語言映象精簡

對於諸如 NodePythonRust 之類的解釋型語言來說,情況就比較複雜一點了。先來看看 Alpine 映象。

Alpine 映象

對於解釋型語言來說,如果程式僅用到了標準庫或者依賴項和程式本身使用的是同一種語言,且無需呼叫 C 庫和外部依賴,那麼使用 Alpine 作為基礎映象一般是沒有啥問題的。一旦你的程式需要呼叫外部依賴,情況就複雜了,想繼續使用 Alpine 映象,就得安裝這些依賴。根據難度可以劃分為三個等級:

  • 簡單:依賴庫有針對 Alpine 的安裝說明,一般會說明需要安裝哪些軟體包以及如何建立依賴關係。但這種情況非常罕見,原因前面也提到了,Alpine 的軟體包數量比大多數流行的發行版要少得多。
  • 中等:依賴庫沒有針對 Alpine 的安裝說明,但有針對別的發行版的安裝說明。我們可以通過對比找到與別的發行版的軟體包相匹配的 Alpine 軟體包(假如有的話)。
  • 困難:依賴庫沒有針對 Alpine 的安裝說明,但有針對別的發行版的安裝說明,但是 Alpine 也沒有與之對應的軟體包。這種情況就必須從原始碼開始構建!

最後一種情況最不推薦使用 Alpine 作為基礎映象,不但不能減小體積,可能還會適得其反,因為你需要安裝編譯器、依賴庫、標頭檔案等等。。。更重要的是,構建時間會很長,效率低下。如果非要考慮多階段構建,就更復雜了,你得搞清楚如何將所有的依賴編譯成二進位制檔案,想想就頭大。因此一般不推薦在解釋型語言中使用多階段構建。

有一種特殊情況會同時遇到 Alpine 的絕大多數問題:將 Python 用於資料科學numpypandas 之類的包都被預編譯成了 wheelwheel 是 Python 新的打包格式,被編譯成了二進位制,用於替代 Python 傳統的 egg 檔案,可以通過 pip 直接安裝。但這些 wheel 都繫結了特定的 C 庫,這就意味著在大多數使用 glibc 的映象中都可以正常安裝,但 Alpine 映象就不行,原因你懂得,前面已經說過了。如果非要在 Alpine 中安裝,你需要安裝很多依賴,重頭構建,耗時又費力,有一篇文章專門解釋了這個問題:使用 Alpine 構建 Pyhton 映象會將構建速度拖慢 50 倍!

既然 Alpine 映象這麼坑,那麼是不是隻要是 Python 寫的程式就不推薦使用 Alpine 映象來構建呢?也不能完全這麼肯定,至少 Python 用於資料科學時不推薦使用 Alpine,其他情況還是要具體情況具體分析,如果有可能,還是可以試一試 Alpine 的。

:slim 映象

如果實在不想折騰,可以選擇一個折衷的映象 xxx:slim。slim 映象一般都基於 Debianglibc,刪除了許多非必需的軟體包,優化了體積。如果構建過程中需要編譯器,那麼 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

再來舉個特殊情況的例子,同時安裝 matplotlibnumpypandas,不同的基礎映象構建的映象大小如下:

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,不用再一個一個嘗試。

5. Rust 語言映象精簡

Rust 是最初由 Mozilla 設計的現代程式語言,並且在 Web 和基礎架構領域中越來越受歡迎。Rust 編譯的二進位制檔案動態連結到 C 庫,可以正常執行於 UbuntuDebianFedora 之類的映象中,但不能執行於 busybox:glibc 中。因為 Rust 二進位制需要呼叫 libdl 庫,busybox:glibc 中不包含該庫。

還有一個 rust:alpine 映象,Rust 編譯的二進位制也可以正常執行其中。

如果考慮編譯成靜態連結,可以參考 Rust 官方文件。在 Linux 上需要構建一個特殊版本的 Rust 編譯器,構建的依賴庫就是 musl libc,你沒有看錯,就是 Alpine 中的那個 musl libc。如果你想獲得更小的映象,請按照文件中的說明進行操作,最後將生成的二進位制檔案扔進 scratch 映象中就好了。

6. 總結

本系列文章的前兩部分介紹了優化 Docker 映象體積的常用方法,以及如何針對不同型別的語言運用這些方法。最後一部分將會介紹如何在減少映象體積的同時,還能減少 I/O 和記憶體使用量,同時還會介紹一些雖然與容器無關但對優化映象有幫助的技術。


Kubernetes 1.18.2 1.17.5 1.16.9 1.15.12離線安裝包釋出地址http://store.lameleg.com ,歡迎體驗。 使用了最新的sealos v3.3.6版本。 作了主機名解析配置優化,lvscare 掛載/lib/module解決開機啟動ipvs載入問題, 修復lvscare社群netlink與3.10核心不相容問題,sealos生成百年證照等特性。更多特性 https://github.com/fanux/sealos 。歡迎掃描下方的二維碼加入釘釘群 ,釘釘群已經整合sealos的機器人實時可以看到sealos的動態。

相關文章