三個技巧幫助Docker映象瘦身

it阿布 發表於 2020-08-01

在構建Docker容器時,應該儘量想辦法獲得體積更小的映象,因為傳輸和部署體積較小的映象速度更快。

但RUN語句總是會建立一個新層,而且在生成映象之前還需要使用很多中間檔案,在這種情況下,該如何獲得體積更小的映象呢?

你可能已經注意到了,大多數Dockerfiles都使用了一些奇怪的技巧:

FROM ubuntu
RUN apt-get update && apt-get install vim

為什麼使用&&?而不是使用兩個RUN語句代替呢?比如:

FROM ubuntu
RUN apt-get update
RUN apt-get install vim

從Docker 1.10開始,COPY、ADD和RUN語句會向映象中新增新層。前面的示例建立了兩個層而不是一個。

 三個技巧幫助Docker映象瘦身

映象的層就像Git的提交(commit)一樣。

Docker的層用於儲存映象的上一版本和當前版本之間的差異。就像Git的提交一樣,如果你與其他儲存庫或映象共享它們,就會很方便。

實際上,當你向登錄檔請求映象時,只是下載你尚未擁有的層。這是一種非常高效地共享映象的方式。

但額外的層並不是沒有代價的。

層仍然會佔用空間,你擁有的層越多,最終的映象就越大。Git儲存庫在這方面也是類似的,儲存庫的大小隨著層數的增加而增加,因為Git必須儲存提交之間的所有變更。

過去,將多個RUN語句組合在一行命令中或許是一種很好的做法,就像上面的第一個例子那樣,但在現在看來,這樣做並不妥。

1. 通過Docker多階段構建將多個層壓縮為一個

當Git儲存庫變大時,你可以選擇將歷史提交記錄壓縮為單個提交。

事實證明,在Docker中也可以使用多階段構建達到類似的目的。

在這個示例中,你將構建一個Node.js容器。

讓我們從index.js開始:

const express = require('express')
const app = express()
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(3000, () => {
console.log(`Example app listening on port 3000!`)
})

和package.json:

{
"name": "hello-world",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"express": "^4.16.2"
},
"scripts": {
"start": "node index.js"
}
}

你可以使用下面的Dockerfile來打包這個應用程式:

FROM node:8
EXPOSE 3000
WORKDIR /app
COPY package.json index.js ./
RUN npm install
CMD ["npm", "start"]

然後開始構建映象:

$ docker build -t node-vanilla .

然後用以下方法驗證它是否可以正常執行:

$ docker run -p 3000:3000 -ti --rm --init node-vanilla

你應該能訪問http://localhost:3000,並收到“Hello World!”。

Dockerfile中使用了一個COPY語句和一個RUN語句,所以按照預期,新映象應該比基礎映象多出至少兩個層:

$ docker history node-vanilla
IMAGE CREATED BY SIZE
075d229d3f48 /bin/sh -c #(nop) CMD ["npm" "start"] 0B
bc8c3cc813ae /bin/sh -c npm install 2.91MB
bac31afb6f42 /bin/sh -c #(nop) COPY multi:3071ddd474429e1… 364B
500a9fbef90e /bin/sh -c #(nop) WORKDIR /app 0B
78b28027dfbf /bin/sh -c #(nop) EXPOSE 3000 0B
b87c2ad8344d /bin/sh -c #(nop) CMD ["node"] 0B
<missing> /bin/sh -c set -ex && for key in 6A010… 4.17MB
<missing> /bin/sh -c #(nop) ENV YARN_VERSION=1.3.2 0B
<missing> /bin/sh -c ARCH= && dpkgArch="$(dpkg --print… 56.9MB
<missing> /bin/sh -c #(nop) ENV NODE_VERSION=8.9.4 0B
<missing> /bin/sh -c set -ex && for key in 94AE3… 129kB
<missing> /bin/sh -c groupadd --gid 1000 node && use… 335kB
<missing> /bin/sh -c set -ex; apt-get update; apt-ge… 324MB
<missing> /bin/sh -c apt-get update && apt-get install… 123MB
<missing> /bin/sh -c set -ex; if ! command -v gpg > /… 0B
<missing> /bin/sh -c apt-get update && apt-get install… 44.6MB
<missing> /bin/sh -c #(nop) CMD ["bash"] 0B
<missing> /bin/sh -c #(nop) ADD file:1dd78a123212328bd… 123MB

但實際上,生成的映象多了五個新層:每一個層對應Dockerfile裡的一個語句。

現在,讓我們來試試Docker的多階段構建。

你可以繼續使用與上面相同的Dockerfile,只是現在要呼叫兩次:

FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM node:8
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

Dockerfile的第一部分建立了三個層,然後這些層被合併並複製到第二個階段。在第二階段,映象頂部又新增了額外的兩個層,所以總共是三個層。

 三個技巧幫助Docker映象瘦身

 

 現在來驗證一下。首先,構建容器:

$ docker build -t node-multi-stage .

檢視映象的歷史:

$ docker history node-multi-stage
IMAGE CREATED BY SIZE
331b81a245b1 /bin/sh -c #(nop) CMD ["index.js"] 0B
bdfc932314af /bin/sh -c #(nop) EXPOSE 3000 0B
f8992f6c62a6 /bin/sh -c #(nop) COPY dir:e2b57dff89be62f77… 1.62MB
b87c2ad8344d /bin/sh -c #(nop) CMD ["node"] 0B
<missing> /bin/sh -c set -ex && for key in 6A010… 4.17MB
<missing> /bin/sh -c #(nop) ENV YARN_VERSION=1.3.2 0B
<missing> /bin/sh -c ARCH= && dpkgArch="$(dpkg --print… 56.9MB
<missing> /bin/sh -c #(nop) ENV NODE_VERSION=8.9.4 0B
<missing> /bin/sh -c set -ex && for key in 94AE3… 129kB
<missing> /bin/sh -c groupadd --gid 1000 node && use… 335kB
<missing> /bin/sh -c set -ex; apt-get update; apt-ge… 324MB
<missing> /bin/sh -c apt-get update && apt-get install… 123MB
<missing> /bin/sh -c set -ex; if ! command -v gpg > /… 0B
<missing> /bin/sh -c apt-get update && apt-get install… 44.6MB
<missing> /bin/sh -c #(nop) CMD ["bash"] 0B
<missing> /bin/sh -c #(nop) ADD file:1dd78a123212328bd… 123MB

檔案大小是否已發生改變?

$ docker images | grep node-
node-multi-stage 331b81a245b1 678MB
node-vanilla 075d229d3f48 679MB

最後一個映象(node-multi-stage)更小一些。

你已經將映象的體積減小了,即使它已經是一個很小的應用程式。

但整個映象仍然很大!

有什麼辦法可以讓它變得更小嗎?

2. 用distroless去除容器中所有不必要的東西

這個映象包含了Node.js以及yarn、npm、bash和其他的二進位制檔案。因為它也是基於Ubuntu的,所以你等於擁有了一個完整的作業系統,其中包括所有的小型二進位制檔案和實用程式。

但在執行容器時是不需要這些東西的,你需要的只是Node.js。

Docker容器應該只包含一個程式以及用於執行這個程式所需的最少的檔案,你不需要整個作業系統。

實際上,你可以刪除Node.js之外的所有內容。

但要怎麼做?

所幸的是,谷歌為我們提供了distroless。

以下是distroless儲存庫的描述:

“distroless”映象只包含應用程式及其執行時依賴項,不包含程式包管理器、shell以及在標準Linux發行版中可以找到的任何其他程式。

這正是你所需要的!

你可以對Dockerfile進行調整,以利用新的基礎映象,如下所示:

FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM gcr.io/distroless/nodejs
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

你可以像往常一樣編譯映象:

$ docker build -t node-distroless .

這個映象應該能正常執行。要驗證它,可以像這樣執行容器:

$ docker run -p 3000:3000 -ti --rm --init node-distroless

現在可以訪問http://localhost:3000頁面。

不包含其他額外二進位制檔案的映象是不是小多了?

$ docker images | grep node-distroless
node-distroless 7b4db3b7f1e5 76.7MB

只有76.7MB!

比之前的映象小了600MB!

但在使用distroless時有一些事項需要注意。

當容器在執行時,如果你想要檢查它,可以使用以下命令attach到正在執行的容器上:

$ docker exec -ti <insert_docker_id> bash

attach到正在執行的容器並執行bash命令就像是建立了一個SSH會話一樣。

但distroless版本是原始作業系統的精簡版,沒有了額外的二進位制檔案,所以容器裡沒有shell!

在沒有shell的情況下,如何attach到正在執行的容器呢?

答案是,你做不到。這既是個壞訊息,也是個好訊息。

之所以說是壞訊息,因為你只能在容器中執行二進位制檔案。你可以執行的唯一的二進位制檔案是Node.js:

$ docker exec -ti <insert_docker_id> node

說它是個好訊息,是因為如果攻擊者利用你的應用程式獲得對容器的訪問許可權將無法像訪問shell那樣造成太多破壞。換句話說,更少的二進位制檔案意味著更小的體積和更高的安全性,不過這是以痛苦的除錯為代價的。

或許你不應在生產環境中attach和除錯容器,而應該使用日誌和監控。

但如果你確實需要除錯,又想保持小體積該怎麼辦?

3. 小體積的Alpine基礎映象

你可以使用Alpine基礎映象替換distroless基礎映象。

Alpine Linux是:

一個基於musl libc和busybox的面向安全的輕量級Linux發行版。

換句話說,它是一個體積更小也更安全的Linux發行版。

不過你不應該理所當然地認為他們聲稱的就一定是事實,讓我們來看看它的映象是否更小。

先修改Dockerfile,讓它使用node:8-alpine:

FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM node:8-alpine
COPY --from=build /app /
EXPOSE 3000
CMD ["npm", "start"]

使用下面的命令構建映象:

$ docker build -t node-alpine .

現在可以檢查一下映象大小:

$ docker images | grep node-alpine
node-alpine aa1f85f8e724 69.7MB
69.7MB!

甚至比distrless映象還小!

現在可以attach到正在執行的容器嗎?讓我們來試試。

讓我們先啟動容器:

$ docker run -p 3000:3000 -ti --rm --init node-alpine
Example app listening on port 3000!

你可以使用以下命令attach到執行中的容器:

$ docker exec -ti 9d8e97e307d7 bash
OCI runtime exec failed: exec failed: container_linux.go:296: starting container process caused "exec: \"bash\": executable file not found in $PATH": unknown

看來不行,但或許可以使用shell?

$ docker exec -ti 9d8e97e307d7 sh / #

成功了!現在可以attach到正在執行的容器中了。

看起來很有希望,但還有一個問題。

Alpine基礎映象是基於muslc的——C語言的一個替代標準庫,而大多數Linux發行版如Ubuntu、Debian和CentOS都是基於glibc的。這兩個庫應該實現相同的核心介面。

但它們的目的是不一樣的:

glibc更常見,速度也更快;

muslc使用較少的空間,並側重於安全性。

在編譯應用程式時,大部分都是針對特定的libc進行編譯的。如果你要將它們與另一個libc一起使用,則必須重新編譯它們。

換句話說,基於Alpine基礎映象構建容器可能會導致非預期的行為,因為標準C庫是不一樣的。

你可能會注意到差異,特別是當你處理預編譯的二進位制檔案(如Node.js C++擴充套件)時。

例如,PhantomJS的預構建包就不能在Alpine上執行。

你應該選擇哪個基礎映象?

你應該使用Alpine、distroless還是原始映象?

如果你是在生產環境中執行容器,並且更關心安全性,那麼可能distroless映象更合適。

新增到Docker映象的每個二進位制檔案都會給整個應用程式增加一定的風險。

只在容器中安裝一個二進位制檔案可以降低總體風險。

例如,如果攻擊者能夠利用執行在distroless上的應用程式的漏洞,他們將無法在容器中使用shell,因為那裡根本就沒有shell!

請注意,OWASP本身就建議儘量減少攻擊表面。

如果你只關心更小的映象體積,那麼可以考慮基於Alpine的映象。

它們的體積非常小,但代價是相容性較差。Alpine使用了略微不同的標準C庫——muslc。你可能會時不時地遇到一些相容性問題。

原始基礎映象非常適合用於測試和開發。

它雖然體積很大,但提供了與Ubuntu工作站一樣的體驗。此外,你還可以訪問作業系統的所有二進位制檔案。

再回顧一下各個映象的大小:

node:8 681MB
node:8 使用多階段構建為678MB
gcr.io/distroless/nodejs 76.7MB
node:8-alpine 69.7MB

更多學習內容可以訪問從碼農成為架構師的修煉之路