Docker 鏡像製做教程:針對不一樣語言的精簡策略

本系列文章將分爲三個部分:html

第一部分着重介紹多階段構建(multi-stage builds),由於這是鏡像精簡之路相當重要的一環。在這部份內容中,我會解釋靜態連接和動態連接的區別,它們對鏡像帶來的影響,以及如何避免那些很差的影響。中間會穿插一部分對 Alpine 鏡像的介紹。連接:Docker 鏡像製做教程:減少鏡像體積java

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

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

本文介紹第二部分。linux

1. Go 語言鏡像精簡

Go 語言程序編譯時會將全部必須的依賴編譯到二進制文件中,但也不能徹底確定它使用的是靜態連接,由於 Go 的某些包是依賴系統標準庫的,例如使用到 DNS 解析的包。只要代碼中導入了這些包,編譯的二進制文件就須要調用到某些系統庫,爲了這個需求,Go 實現了一種機制叫 cgo,以容許 Go 調用 C 代碼,這樣編譯好的二進制文件就能夠調用系統庫。git

也就是說,若是 Go 程序使用了 net 包,就會生成一個動態的二進制文件,若是想讓鏡像可以正常工做,必須將須要的庫文件複製到鏡像中,或者直接使用 busybox:glibc 鏡像。github

固然,你也能夠禁止 cgo,這樣 Go 就不會使用系統庫,使用內置的實現來替代系統庫(例如使用內置的 DNS 解析器),這種狀況下生成的二進制文件就是靜態的。能夠經過設置環境變量 CGO_ENABLED=0 來禁用 cgo,例如:golang

FROM golang
COPY whatsmyip.go . ENV CGO_ENABLED=0
RUN go build whatsmyip.go 
FROM scratch
COPY --from=0 /go/whatsmyip . CMD ["./whatsmyip"] 複製代碼

因爲編譯生成的是靜態二進制文件,所以能夠直接跑在 scratch 鏡像中 🎉docker

固然,也能夠不用徹底禁用 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生成百年證書等特性。更多特性 github.com/fanux/sealo… 。歡迎掃描下方的二維碼加入釘釘羣 ,釘釘羣已經集成sealos的機器人實時能夠看到sealos的動態。

相關文章
相關標籤/搜索