在構建 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 語句會向鏡像中添加新層。前面的示例創建了兩個層而不是一個。
鏡像的層就像 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 .
參數:
-t, --tag value
:命名一個 tag 爲 name:tag
格式(默認爲 [])
然後用以下方法驗證它是否可以正常運行:
$ docker run -p 3000:3000 -ti --rm --init node-vanilla
參數說明:
-p, --publish value
:將容器的端口映射到主機端口(默認爲 [])-t, --tty
:分配一個僞 TTY-i, --interactive
:保持標準輸入--rm
:當容器退出時自動刪除
你應該能訪問 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 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](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
轉自於:https://www.infoq.cn/article/3-simple-tricks-for-smaller-docker-images
英文原文:https://itnext.io/3-simple-tricks-for-smaller-docker-images-f0d2bda17d1e