歡迎光臨
每天分享高質量文章

Docker容器鏡像瘦身的三個小竅門

在構建Docker容器時,我們應盡可能減小鏡像的大小。使用共享層的鏡像尺寸越小,其傳輸和部署速度越快。
不過在每個RUN陳述句都會創建一個新層的情況下,如果我們需要獲取鏡像完成前的中間產物,又如何控制其大小呢?
你可能已經註意到市面上多數的Dockerfile都會使用類似這樣的招數:
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提交類似。
Docker層儲存了鏡像上一版本和當前版本之間的差異。與Git提交類似,層有利於與其他倉庫或鏡像進行共享。
實際上,當我們從Registry請求鏡像時,我們只會下載那些不存在的層。這種方式讓鏡像共享更高效。
但是,層是有代價的。
層會占用空間,層越多,最終的鏡像就越大。Git倉庫在這方面是相似的。因為Git需要儲存提交之間的所有變更,倉庫的大小會隨著層數的增加而增加。
在過去,做法就是像第一個例子那樣將幾個RUN陳述句合併在一行中。
然後就沒有然後了。
使用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
      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
      /bin/sh -c set -ex   && for key in     94AE3…   129kB
      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
      /bin/sh -c apt-get update && apt-get install…   123MB
      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
      /bin/sh -c apt-get update && apt-get install…   44.6MB
      /bin/sh -c #(nop)  CMD ["
bash"]                 0B
      /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

是的,新的鏡像要小一點點。
看起來還不錯!儘管應用本身已經做了精簡,我們還是減少了其整體大小。
不過,鏡像依然很大!
要讓它變得更小一點,我們還能做點什麼?
使用Distroless移除容器中的所有累贅

目前的鏡像不僅含有Node.js,還含有yarn、npm、bash以及大量其他二進制檔案。同時,它是基於Ubuntu的。因此擁有一個完整的操作系統以及所有的二進制檔案和實用程式。
這些在運行容器時都不是必需的。我們唯一的依賴項是Node.js。
Docker容器應封裝在單一行程中,且只包含運行所需的最精簡內容。我們不需要一個操作系統。
實際上,除了Node.js,其他都可以移除。
那麼要怎麼做呢?
幸運的是,Google也有同樣的想法,他們帶來了GoogleCloudPlatform/distroless[1]。
有如其倉庫說明所述:
“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.76MB!

比前一個鏡像少了600MB!
真是個好訊息!不過在使用Distroless時有些事項需要註意。
容器運行時,如果想對其進行檢查,可以這麼做:
$ docker exec -ti  bash

上述命令將附加到容器中並運行bash,這與發起一個SSH會話相近。
不過由於Distroless是原始操作系統的精簡版本,不包含額外的程式。容器里並沒有Shell!
如果沒有Shell,要如何附加到運行的容器中呢?
好訊息和壞訊息是,做不到。
壞訊息是我們只能運行容器中的二進製程式。這裡能運行的只有Node.js:
$ docker exec -ti  node

好訊息是因為沒有Shell,如果黑客入侵了我們的應用程式並獲取了容器的訪問權限,他也無法造成太大的損害。也就是說,程式越少則尺寸越小也越安全。不過,代價是除錯更麻煩。
需要註意的是,我們不應該在生產環境中附加到容器中進行除錯,而應依靠正確的日誌和監控。
如果我們既希望能除錯,又關心尺寸大小,又該怎麼辦?
使用Alpine作為更小的基礎鏡像

我們可以使用Alpine取代Distroless來作為基礎鏡像。
Alpine Linux[2]是:
一個基於musl libc[3]和busybox[4]、面向安全的輕量級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!

甚至比Distroless鏡像還要小!
我們來看看能不能附加運行中的容器。
首先,啟動容器:
$ docker run -p 3000:3000 -ti --rm --init node-alpine
Example app listening on port 3000!

現在附加到容器中:
$ 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

運氣不佳。但或許容器有sh這個Shell?
$ docker exec -ti 9d8e97e307d7 sh
#

很好!我們既可以附加到運行的容器中,得到的鏡像尺寸也很小。
聽起來很棒,不過有一個小問題。
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,因為根本就沒有。
註意:最小化攻擊面是OWASP的推薦做法[5]。
如果更在意要是大小,則可以換成Alpine基礎鏡像。

這兩個都很小,代價是兼容性。Alpine用了一個稍稍有點不一樣的C標準庫——muslc。時不時會碰到點兼容性的問題。比如這個[6]和這個[7]。

原生基礎鏡像非常適合用於測試和開發。
它的尺寸比較大,不過用起來就像你主機上安裝的Ubuntu一樣。並且,你能訪問該操作系統里有的所有二進製程式。
下麵,回顧一下各個鏡像大小:
node:8 681MB
node:8結合多階段構建 678MB
gcr.io/distroless/nodejs 76.7MB
node:8-alpine 69.7MB
相關鏈接:
  1. https://github.com/GoogleCloudPlatform/distroless

  2. https://alpinelinux.org

  3. https://www.musl-libc.org

  4. https://www.busybox.net

  5. https://www.owasp.org/index.php/Minimize_attack_surface_area

  6. https://github.com/grpc/grpc/issues/8528

  7. https://github.com/grpc/grpc/issues/6126

原文鏈接:https://itnext.io/3-simple-tricks-for-smaller-docker-images-f0d2bda17d1e

3天Kubernetes線下實戰培訓

Kubernetes應用實戰培訓將於2018年9月14日在上海開課,3天時間帶你系統掌握Kubernetes本次培訓包括:容器特性、鏡像、網絡;Docker特性、架構、組件、概念、Runtime;Docker安全;Docker實踐;Kubernetes架構、核心組件、基本功能;Kubernetes設計理念、架構設計、基本功能、常用物件、設計原則;Kubernetes的實踐、運行時、網絡、插件已經落地經驗;微服務架構、DevOps等,點擊下方圖片查看詳情。

赞(0)

分享創造快樂