突破難關:Docker映象和容器的區別以及構建的最佳實踐

張晉濤發表於2023-03-26

大家好,我是張晉濤。

本週 Docker 就釋出 10 週年了,為了慶祝這個里程碑,我將會發布一系列文章,涉及 Docker,CI/CD, 容器等各個方面。

Docker 可謂是開啟了容器化技術的新時代,現在無論大中小公司基本上都對容器化技術有不同程度的嘗試,或是已經進行了大量容器化的改造。伴隨著 Kubernetes 和 Cloud Native 等技術和理念的普及,也大大增加了業務容器化需求。而這一切的推進,不可避免的技術之一便是構建容器映象。

Docker 映象是什麼

在真正實踐之前,我們需要先搞明白幾個問題:

  • Docker 映象是什麼
  • Docker 映象的作用
  • 容器和映象的區別及聯絡

Docker 映象是什麼

這裡,我們以一個 Debian 系統的映象為例。透過 docker run --it debian 可以啟動一個 debian 的容器,終端會有如下輸出:

/ # docker run -it debian
Unable to find image 'debian:latest' locally
latest: Pulling from library/debian
c5e155d5a1d1: Pull complete 
Digest: sha256:f81bf5a8b57d6aa1824e4edb9aea6bd5ef6240bcc7d86f303f197a2eb77c430f
Status: Downloaded newer image for debian:latest
root@860f21595fb6:/# cat /etc/os-release 
PRETTY_NAME="Debian GNU/Linux 11 (bullseye)"
NAME="Debian GNU/Linux"
VERSION_ID="11"
VERSION="11 (bullseye)"
VERSION_CODENAME=bullseye
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

看終端的日誌,Docker CLI 首先會查詢本地是否有 debian 的映象,如果沒有則從映象倉庫(若不指定,預設是 DockerHub)進行 pull;
將映象 pull 到本地後,再以此映象來啟動容器。

我們可以先退出此容器,來看看 Docker 映象到底是什麼。用 docker image ls 來檢視已下載好的映象:

(MoeLove) ➜ docker image ls debian
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
debian       latest    72b624312240   2 weeks ago   124MB

docker image save 命令將映象儲存成一個 tar 檔案:

(MoeLove) ➜ mkdir debian-image
(MoeLove) ➜ docker image save -o debian-image/debian.tar debian
(MoeLove) ➜ ls debian-image/
debian.tar

將映象檔案進行解壓:

(MoeLove) ➜ tar -C debian-image/ -xf debian-image/debian.tar 
(MoeLove) ➜ tree -I debian.tar debian-image/
debian-image/
├── 72b6243122405be2c5c5e7e20d410f4c8fe301e1ce84cc60ea591b63167750e6.json
├── 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2
│   ├── VERSION
│   ├── json
│   └── layer.tar
├── manifest.json
└── repositories

1 directory, 6 files

可以看到將映象檔案解壓後,包含的內容主要是一些配置檔案和 tar 包。

接下來我們來具體看看其中的內容,並透過這些內容來理解映象的組成。

manifest.json

(MoeLove) ➜ cd debian-image/
(MoeLove) ➜ cat manifest.json | jq
[
  {
    "Config": "72b6243122405be2c5c5e7e20d410f4c8fe301e1ce84cc60ea591b63167750e6.json",
    "RepoTags": [
      "debian:latest"
    ],
    "Layers": [
      "7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer.tar"
    ]
  }
]

注意:在實際儲存時,是不包含換行的,這裡為了便於展示所以使用了 jq 工具進行格式化。

manifest.json 包含了映象的頂層配置,它是一系列配置按順序組織而成的;以現在我們的 debian 映象為例,它至包含了一組配置,這組配置中包含了 3 個主要的資訊,我們由簡到繁進行說明。

RepoTags

RepoTags 表示映象的名稱和 tag ,這裡簡要的對此進行說明:RepoTags 其實分為兩部分:

  • Repo: Docker 映象可以儲存在本地或者遠端映象倉庫內,Repo 其實就是映象的名稱。 Docker 預設提供了大量的官方映象儲存在 Docker Hub 上,對於我們現在在用的這個 Docker 官方的 debian 映象而言,完整的儲存形式其實是 docker.io/library/debian,只不過 docker 自動幫我們省略掉了字首。
  • Tag: 我們可以透過 repo:tag 的方式來引用一個映象,預設情況下,如果沒有指定 tag (像我們上面操作的那樣),則會 pull 下來最新的映象(即:latest)

Config

Config 欄位包含的內容是映象的全域性配置。我們來看看具體內容:

(MoeLove) ➜ cat 72b6243122405be2c5c5e7e20d410f4c8fe301e1ce84cc60ea591b63167750e6.json | jq                                                                                    
{                                                                                      
  "architecture": "amd64",                                                             
  "config": {                                                                          
    "Hostname": "",                                                                    
    "Domainname": "",                                                                  
    "User": "",                                                                        
    "AttachStdin": false,                                                              
    "AttachStdout": false,                                                             
    "AttachStderr": false,                                                             
    "Tty": false,                                                                      
    "OpenStdin": false,                                                                
    "StdinOnce": false,                                                                
    "Env": [                                                                           
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"                                                                                                     
    ],                                                                                 
    "Cmd": [                                                                           
      "bash"                                                                           
    ],                                                                                 
    "Image": "sha256:f8f185aa88c5b07710b327c1c8fd02c8d264bdcce11877d337b9d5c739015cea",                                                                                       
    "Volumes": null,                                                                   
    "WorkingDir": "",                                                                  
    "Entrypoint": null,                                                                
    "OnBuild": null,                                                                   
    "Labels": null                                                                     
  },                                                                                   
  "container": "f41eadbc246cbece89086679da07f3b0d1508234aab4932acab7cbdc8ae63a9c",                                                                                            
  "container_config": {                                                                
    "Hostname": "f41eadbc246c",                                                        
    "Domainname": "",                                                                  
    "User": "",                                                                        
    "AttachStdin": false,                                                              
    "AttachStdout": false,                                                             
    "AttachStderr": false,                                                             
    "Tty": false,                                                                      
    "OpenStdin": false,                                                                
    "StdinOnce": false,                                                                
    "Env": [                                                                           
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"                                                                                                     
    ],                                                                                 
    "Cmd": [                                                                           
      "/bin/sh",                                                                       
      "-c",                                                                            
      "#(nop) ",                                                                       
      "CMD [\"bash\"]"                                                                 
    ],                                                                                 
    "Image": "sha256:f8f185aa88c5b07710b327c1c8fd02c8d264bdcce11877d337b9d5c739015cea",                                                                                       
    "Volumes": null,                                                                   
    "WorkingDir": "",                                                                  
    "Entrypoint": null,                                                                
    "OnBuild": null,                                                                   
    "Labels": {}                                                                       
  },                                                                                   
  "created": "2023-03-01T04:09:46.527045822Z",                                         
  "docker_version": "20.10.23",                                                        
  "history": [                                                                         
    {                                                                                  
      "created": "2023-03-01T04:09:45.982020208Z",                                     
      "created_by": "/bin/sh -c #(nop) ADD file:513c5d5e501279c21a05c1d8b66e5f0b02ee4b27f0b928706d92fd9ce11c1be6 in / "                                                                                                                                                                                                                                     
    },                                                                                 
    {                                                                                  
      "created": "2023-03-01T04:09:46.527045822Z",                                     
      "created_by": "/bin/sh -c #(nop)  CMD [\"bash\"]",                               
      "empty_layer": true                                                              
    }                                                                                  
  ],                                                                                   
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:cf2e8433dbf248a87d49abe6aa4368bb100969be2267db02015aa9c38d7225ed"
    ]
  }
}

以上是配置檔案的全部內容。其含義如下:

  • architectureos : 表示架構及系統不再展開;
  • docker_version : 構建映象時所用 docker 的版本;
  • created:映象構建完成的時間;
  • history: 映象構建的歷史記錄,後面內容中再詳細介紹;
  • rootfs: 映象的根檔案系統;

重點介紹下 rootfs:我們知道 rootfs 其實是指 / 下一系列檔案目錄的組織結構;雖然 Docker 容器與我們的主機(或者稱之為宿主機)共享同一個 Linux 核心,但它也有自己完整的 rootfs;

如果我們使用 debian:latest 啟動一個容器則可以看到如下內容:

/# tree -L 1 /
/
|-- bin
|-- boot
|-- dev
|-- etc
|-- home
|-- lib
|-- lib64
|-- media
|-- mnt
|-- opt
|-- proc
|-- root
|-- run
|-- sbin
|-- srv
|-- sys
|-- tmp
|-- usr
`-- var

19 directories, 0 files

可以看到與我們正常 Linux 系統的 / 下目錄相同。

回到這個例子當中,我們來看看這段配置的具體含義。由於一開始在 manifest.json 中已經定義了 layer 的內容,我們來看看該 layer 的 sha256sum 值:

(MoeLove) ➜ ls 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2
VERSION  json  layer.tar
(MoeLove) ➜ sha256sum 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer.tar 
cf2e8433dbf248a87d49abe6aa4368bb100969be2267db02015aa9c38d7225ed  7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer.tar

可以看到與 Config 欄位配置檔案中相符,表示 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer.tar 便是 debian 映象的 rootfs 我們將它進行解壓,看看它的內容。

(MoeLove) ➜ mkdir 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer
(MoeLove) ➜ tar -C 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer -xf 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer.tar
(MoeLove) ➜ ls 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

可以看到它的內容確實是 rootfs 應該有的內容。同時,上面操作中也包含了一個知識點:

Docker 映象相關的配置中,所用的 id 或者檔名/目錄名大多是採用 sha256sum 計算得出的

關於配置的部分我們先談這些,我們繼續看配置中尚未解釋的 Layers

Layers

其實根據前面的介紹,我們已經大致看到,Docker 映象是分層的模式,將一系列層按順序組織起來加上配置檔案等共同構成完整的映象。這樣做的好處主要有:

  • 相同內容可以複用, 減輕儲存負擔;
  • 可以比較容易的得到各層所做操作/操作後結果的記錄;
  • 後續操作不影響前一層的內容;

透過 manifest.json 的內容,和前面對 rootfs 的解釋,不難看出此映象只包含了一層,即 7a66e59f40fd03d0e7bfaebe419af6a2c409ef8f513d037e3b1ebb8cbc803ec2/layer.tar

Docker 提供了一個命令可以更加直觀的看到構建記錄:

(MoeLove) ➜ docker image history debian
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
72b624312240   2 weeks ago   /bin/sh -c #(nop)  CMD ["bash"]                 0B        
<missing>      2 weeks ago   /bin/sh -c #(nop) ADD file:513c5d5e501279c21…   124MB

它的輸出相比我們上面配置檔案中的內容,多了一列 SIZE,表示該構建步驟所佔空間大小。可以看到第二步(輸出是逆序的) /bin/sh -c #(nop) CMD ["bash"] 所佔空間為 0 。

我們首先分解這些步驟所表示的內容:

  • /bin/sh -c #(nop) ADD file:caf91edab64f988bc… : 使用 ADD 命令新增檔案;
  • /bin/sh -c #(nop) CMD ["bash"]:使用 CMD 配置預設執行的程式是 bash ;

從前面 Config 的配置中,我們也可以看到第二步其實是修改了 Config 的配置,所以佔用空間為 0,並沒有使映象變大。

從 Docker Hub 上我們也可以找到此映象的 Dockerfile 檔案 https://github.com/debuerreotype/docker-debian-artifacts/blob/fe5738569aad49a97cf73183a8a6b2732fe57840/bullseye/Dockerfile ,看下具體內容:

FROM scratch
ADD rootfs.tar.xz /
CMD ["bash"]

步驟與我們上面提到的完全符合, 不再進行展開了。

以上便詳細解釋了 Docker 映象是什麼: 它其實是一組按照規範進行組織的分層檔案,各層互不影響,並且每層的操作都將記錄在 history 中。

Docker 映象的作用

從前面的講述中,我們可以看到映象中包含了一個完整的 rootfs ,在我們使用 docker run 命令時,便將指定映象中的各層和配置組織起來共同啟動一個新的容器;而在容器中,我們可以隨意進行操作(包括讀寫)。

所以Docker 映象的主要作用是:

  • 為啟動容器提供必要的檔案;
  • 記錄了各層的操作和配置等;

容器和映象的區別及聯絡

這裡可以直接得出一個很直觀的結論了。

映象就是一系列檔案和配置的組合,它是靜態的,只讀的,不可修改的。

而容器是映象的例項化,它是可操作的,是動態的,可修改的。

Docker 映象常規管理操作

Docker 由於不斷增加新功能,為了方便,在後續版本中便對命令進行了分組。對映象相關的命令都放到了 docker image 組內:

(MoeLove) ➜ docker image

Usage:  docker image COMMAND

Manage images

Commands:
  build       Build an image from a Dockerfile
  history     Show the history of an image
  import      Import the contents from a tarball to create a filesystem image
  inspect     Display detailed information on one or more images
  load        Load an image from a tar archive or STDIN
  ls          List images
  prune       Remove unused images
  pull        Download an image from a registry
  push        Upload an image to a registry
  rm          Remove one or more images
  save        Save one or more images to a tar archive (streamed to STDOUT by default)
  tag         Create a tag TARGET_IMAGE that refers to SOURCE_IMAGE

Run 'docker image COMMAND --help' for more information on a command.

對於我們開始時對映象進行分析的操作,我們可以直接透過 docker image inspect debian 直接拿到它的配置資訊。

pull, push, tag 這三個子命令與和映象倉庫的互動比較相關,可以結合前面 RepoTags 理解。

saveload 是將映象儲存到檔案系統上及從檔案系統中匯入 Docker 中。

build 命令會在接下來詳細說明,剩餘命令都比較簡單直觀了。

如何構建 Docker 映象

前面詳細講述了 Docker 映象是什麼,以及簡單介紹了常用的 Docker 映象管理命令。那如何構建一個 Docker 映象呢?通常情況下,有兩種辦法可以用於構建映象(但並不只有這兩種辦法,後續再寫文章來單獨講 flag++)

從容器建立

還是以 debian 映象為例,使用官方的 debian 映象,啟動一個容器:

(MoeLove) ➜ docker run --rm -it debian
root@642741c96f0c:/# toilet
bash: toilet: command not found

容器啟動後,我們輸入 toilet 來檢視當前是否有 toilet 這個命令。 這是一個能將輸入的字串以更大的文字輸出的命令列工具。

看上面的輸入,當前的 PATH 中並沒有該命令。我們使用 apt 進行安裝。

root@642741c96f0c:/# apt-get update -qq && apt-get install toilet -y -qq
debconf: delaying package configuration, since apt-utils is not installed
Selecting previously unselected package libncursesw6:amd64.
(Reading database ... 6661 files and directories currently installed.)
Preparing to unpack .../0-libncursesw6_6.2+20201114-2_amd64.deb ...
Unpacking libncursesw6:amd64 (6.2+20201114-2) ...
Selecting previously unselected package libslang2:amd64.
Preparing to unpack .../1-libslang2_2.3.2-5_amd64.deb ...
Unpacking libslang2:amd64 (2.3.2-5) ...
Selecting previously unselected package libcaca0:amd64.
Preparing to unpack .../2-libcaca0_0.99.beta19-2.2_amd64.deb ...
Unpacking libcaca0:amd64 (0.99.beta19-2.2) ...
Selecting previously unselected package libgpm2:amd64.
Preparing to unpack .../3-libgpm2_1.20.7-8_amd64.deb ...
Unpacking libgpm2:amd64 (1.20.7-8) ...
Selecting previously unselected package toilet-fonts.
Preparing to unpack .../4-toilet-fonts_0.3-1.3_all.deb ...
Unpacking toilet-fonts (0.3-1.3) ...
Selecting previously unselected package toilet.
Preparing to unpack .../5-toilet_0.3-1.3_amd64.deb ...
Unpacking toilet (0.3-1.3) ...
Setting up toilet-fonts (0.3-1.3) ...
Setting up libgpm2:amd64 (1.20.7-8) ...
Setting up libslang2:amd64 (2.3.2-5) ...
Setting up libncursesw6:amd64 (6.2+20201114-2) ...
Setting up libcaca0:amd64 (0.99.beta19-2.2) ...
Setting up toilet (0.3-1.3) ...
update-alternatives: using /usr/bin/figlet-toilet to provide /usr/bin/figlet (figlet) in auto mode
Processing triggers for libc-bin (2.31-13+deb11u5) ...

可以看到,安裝已經完成,我們在終端下輸入 toilet MoeLove 來檢視下效果:

root@642741c96f0c:/# toilet MoeLove
                                                 
 m    m               m                          
 ##  ##  mmm    mmm   #       mmm   m   m   mmm  
 # ## # #" "#  #"  #  #      #" "#  "m m"  #"  # 
 # "" # #   #  #""""  #      #   #   #m#   #"""" 
 #    # "#m#"  "#mm"  #mmmmm "#m#"    #    "#mm" 

該命令已經安裝完成,並工作良好。現在我們使用當前容器來建立一個包含 toilet 命令的 Docker 映象。

Docker 提供了一個命令 docker container commit 用於從容器建立一個映象。

(MoeLove) ➜ docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED         STATUS         PORTS     NAMES
642741c96f0c   debian    "bash"    2 minutes ago   Up 2 minutes             exciting_wu
(MoeLove) ➜ 
(MoeLove) ➜ docker container commit -m "install toilet" 642741c96f0c local/debian:toilet
sha256:214051a092243edfbeb0c6ef8855646aac404425eb81d44c2bce5260b2bc5ce4
(MoeLove) ➜ docker image ls local/debian:toilet
REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
local/debian   toilet    214051a09224   7 seconds ago   146MB

直接將當前容器的 ID 傳遞給 docker container commit 作為引數,並提供一個新的映象名稱便可建立一個新的映象(傳遞名稱是為了方便使用,即使不傳遞名稱也可以建立映象)

使用新的映象來啟動一個容器進行驗證:

(MoeLove) ➜ docker run --rm -it local/debian:toilet
root@9968f2a887f1:/# toilet debian
                                          
     #         #        "                 
  mmm#   mmm   #mmm   mmm     mmm   m mm  
 #" "#  #"  #  #" "#    #    "   #  #"  # 
 #   #  #""""  #   #    #    m"""#  #   # 
 "#m##  "#mm"  ##m#"  mm#mm  "mm"#  #   # 
                                          
                          

可以看到 toilet 已經存在。從容器建立映象的目的達成。

從 Dockerfile 建立

Docker 提供了一種可根據配置檔案構建映象的方式,該配置檔案通常命名為 Dockerfile。我們將剛才建立映象的過程以 Dockerfile 進行描述。

/ # mkdir toilet       
/ # cd toilet/
/toilet # vi Dockerfile
/toilet # cat Dockerfile 
FROM debian

RUN apt-get update -qq && apt-get install toilet -y -qq

Dockerfile 語法是固定的,但本篇不會對全部語法逐個解釋,如有興趣可查閱官方檔案 。接下來使用該 Dockerfile 構建映象。

(MoeLove) ➜ docker image build -t local/debian:toilet-using-dockerfile .
[+] Building 4.6s (6/6) FINISHED                                                                                               
 => [internal] load build definition from Dockerfile                                                                      0.0s
 => => transferring dockerfile: 106B                                                                                      0.0s
 => [internal] load .dockerignore                                                                                         0.0s
 => => transferring context: 2B                                                                                           0.0s
 => [internal] load metadata for docker.io/library/debian:latest                                                          0.0s
 => [1/2] FROM docker.io/library/debian                                                                                   0.0s
 => [2/2] RUN apt-get update -qq && apt-get install toilet -y -qq                                                         4.1s
 => exporting to image                                                                                                    0.5s 
 => => exporting layers                                                                                                   0.5s 
 => => writing image sha256:247bdcfbeb4dd0ef62732040edd3de36b72aa46f8f0392462db1a82276bb23db                              0.0s 
 => => naming to docker.io/local/debian:toilet-using-dockerfile                                                           0.0s
(MoeLove) ➜ docker image ls local/debian
REPOSITORY     TAG                       IMAGE ID       CREATED          SIZE                                                  
local/debian   toilet-using-dockerfile   247bdcfbeb4d   30 seconds ago   146MB
local/debian   toilet                    214051a09224   4 minutes ago    146MB

使用 -t 引數來指定新生成映象的名稱,並且我們也可以看到該映象已經構建成功。同樣的使用該映象建立容器進行測試:

/toilet # docker run --rm -it local/debian:toilet-using-dockerfile
root@d4f191b8d653:/# toilet debian
                                          
     #         #        "                 
  mmm#   mmm   #mmm   mmm     mmm   m mm  
 #" "#  #"  #  #" "#    #    "   #  #"  # 
 #   #  #""""  #   #    #    m"""#  #   # 
 "#m##  "#mm"  ##m#"  mm#mm  "mm"#  #   # 
                                          
                   

也都驗證成功。如果你重複執行 docker build 命令的話,會看到有 cache 字樣的輸出,這是因為 Docker 為了提高構建映象的效率,對已經構建過的每層進行了快取,後面的內容會再講到快取相關的內容。

以上便是兩種最常見構建容器映象的方法了。其他辦法之後寫文章單獨再聊。

逐步分解構建 Docker 映象的最佳實踐

從容器構建 VS 從 Dockerfile 構建

透過上面的介紹也可以看到,從容器構建很簡單很直接,從 Dockerfile 構建則需要你描述出來每一步所做內容。

但是,如果對構建過程會有修改,或者是想要可維護,可記錄,可追溯,那還是選擇 Dockerfile 更為恰當。

以一個 Spring Boot 的專案為例

(MoeLove) ➜  spring-boot-hello-world git:(master) ✗ ls -l 
總用量 20
-rw-rw-r--. 1 tao tao    0 3月  15 06:52 Dockerfile
drwxrwxr-x. 2 tao tao 4096 3月  15 06:54 docs
-rw-rw-r--. 1 tao tao 1992 3月  15 06:33 pom.xml
-rw-rw-r--. 1 tao tao   89 3月  15 06:50 README.md
drwxrwxr-x. 4 tao tao 4096 3月  15 06:33 src
drwxrwxr-x. 9 tao tao 4096 3月  15 06:52 target

這裡雖然以 Spring Boot 專案為例,但你如果對 Spring Boot 不熟悉的話也完全不影響後續內容,這裡並不涉及 Spring Boot 的任何知識。你只需要知道對於這個專案而言,需要先裝依賴,構建,才能執行。

那我們來看看一般情況下,對於這樣的專案 Dockerfile 的內容是什麼樣的。

利用快取

FROM debian

COPY . /app

RUN apt update
RUN apt install -y openjdk-17-jdk

CMD [ "java", "-jar", "/app/target/gs-spring-boot-0.1.0.jar" ]

這是一種比較典型的,在本地先構建好之後,再複製到容器映象中。注意,由於 debian 映象預設沒有 Java 環境,所以還需要有 apt/apt-get 來安裝 Java 環境。

那這樣的 Dockerfile 有問題嗎?有。

前面我們提到了,如果你對同樣內容的 Dockerfile 執行兩次 docker build 命令的話,會看到有 cache 字樣的輸出,這是因為 Docker 的 build 系統內建了快取的邏輯,在構建時,會檢查當前要構建的內容是否已經被快取,如果被快取則直接使用,否則重新構建,並且後續的快取也將失效。

對於一個正常的專案而言,原始碼的更新是最為頻繁的。所以看上面的 Dockerfile 你會發現 COPY . /app 這一行,很容易就會讓快取失效,從而導致後面的快取也都失效。

對此 Dockerfile 進行改進:

FROM debian

RUN apt update
RUN apt install -y openjdk-17-jdk

COPY . /app

CMD [ "java", "-jar", "/app/target/gs-spring-boot-0.1.0.jar" ]

第一個實踐指南: 為了更有效的利用構建快取,將更新最頻繁的步驟放在最後面 這樣在之後的構建中,前三步都可以利用快取。你可以執行多次 docker build 以進行驗證。

部分複製

在專案變大,或者是專案中其他目錄,比如 docs 目錄內容很大時,根據前面對映象相關的說明,直接使用 COPY . /app 會把所有內容複製至映象中,導致映象變大。

而對於我們要構建的映象而言,那些檔案是不必要的,所以我們可以將 Dockerfile 改成這樣:

FROM debian

RUN apt update
RUN apt install -y openjdk-17-jdk

COPY target/gs-spring-boot-0.1.0.jar /app/

CMD [ "java", "-jar", "/app/gs-spring-boot-0.1.0.jar" ]

第二個實踐指南: 避免將全部內容複製至映象中, 至保留需要的內容即可 。當然除去修改 Dockerfile 檔案外,也可以透過修改 .dockerignore 檔案來完成類似的事情。

docker build 的過程是先載入 .dockerignore 檔案,然後才按照 Dockerfile 進行構建,.dockerignore 的用法與 .gitignore 類似,排除掉你不想要的檔案即可。

防止包快取過期

上面我們已經提到了, docker build 可以利用快取,但你有沒有考慮到,如果使用我們前面的 Dockerfile,當你機器上需要構建多個不同專案的映象,或者是需要安裝的依賴發生變化的時候,快取可能就不是我們想要的了。

比如說,我想安裝一個最新版的 vim 在映象中,可以簡單的修改第三行為 RUN apt install -y openjdk-17-jdk vim ,但由於 RUN apt update 是被快取的,所以我無法安裝到最新版本的 vim

FROM debian

RUN apt update && apt install -y openjdk-17-jdk

COPY target/gs-spring-boot-0.1.0.jar /app/

CMD [ "java", "-jar", "/app/gs-spring-boot-0.1.0.jar" ]

第三個實踐指南: 將包管理器的快取生成與安裝包的命令寫到一起可防止包快取過期

謹慎使用包管理器

瞭解 apt/apt-get 的朋友應該知道,在使用 apt/apt-get 安裝包的時候,它會自動增加一些推薦安裝的包,並且一同下載。但那些包對我們映象中跑應用程式而言無關緊要。它有一個 --no-install-recommends 的選項可以避免安裝那些推薦的包。

我們先來看下是否使用此選項的區別,我啟動一個 debian 的容器進行測試:

root@5a23eb858163:/# apt install  --no-install-recommends openjdk-17-jdk | grep 'additional disk space will be used'
...
After this operation, 344 MB of additional disk space will be used.
^C
root@5a23eb858163:/# apt install openjdk-17-jdk | grep 'additional disk space will be used'
...
After this operation, 548 MB of additional disk space will be used.
^C

可以看到如果增加了 --no-install-recommends 選項的話,可以減少 200M 左右磁碟佔用。

所以 Dockerfile 可以修改為:

FROM debian

RUN apt update && apt install -y --no-install-recommends openjdk-17-jdk

COPY target/gs-spring-boot-0.1.0.jar /app/

CMD [ "java", "-jar", "/app/gs-spring-boot-0.1.0.jar" ]

此時構建映象,我們來與之前的映象做下對比:

(MoeLove) ➜  docker image ls local/spring-boot
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
local/spring-boot   4                   716523c83a26        3 minutes ago       497MB
local/spring-boot   2                   178dacdaf015        9 hours ago         600MB

可以很明顯看到映象明顯變小了。

接下來還有個值得注意的地方。我們一開始執行了 apt update 這個命令,它主要是在快取源資訊。而對於我們構建所需映象時,這沒有必要。我們選擇將這些快取檔案刪掉。

啟動一個新的容器驗證下:

(MoeLove) ➜  docker run --rm -it debian
root@cd857c3ab882:/# apt -qq  update 
All packages are up to date.
root@cd857c3ab882:/# du -sh /var/lib/apt/lists/
16M     /var/lib/apt/lists/
root@cd857c3ab882:/# 

可以看到有 16M 左右的大小,我們修改 Dockerfile 增加刪除操作:

FROM debian

RUN apt update && apt install -y --no-install-recommends openjdk-17-jdk \
        && rm -rf /var/lib/apt/lists/*  

COPY target/gs-spring-boot-0.1.0.jar /app/

CMD [ "java", "-jar", "/app/gs-spring-boot-0.1.0.jar" ]

對比使用這個 Dockerfile 構建映象的映象大小

(MoeLove) ➜  docker image ls local/spring-boot                    
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE                     
local/spring-boot   4-2                 ac272f3dcac2        24 seconds ago      481MB                    
local/spring-boot   4                   716523c83a26        37 minutes ago      497MB                    
local/spring-boot   2                   178dacdaf015        10 hours ago        600MB

可以看到小了 16M 左右。

第四個實踐指南: 謹慎使用包管理器,不安裝非必要的包,注意清理包管理器快取檔案

選擇合適的基礎映象

Docker Hub 上提供了很多 官方映象 這些映象的構建基本上都經過了大量的最佳化,儘可能縮小映象體積,減少映象層數。

當我們構建映象的時候,不妨先檢視官方映象是否有滿足需求的映象可以作為基礎映象。Java 執行環境官方映象是有提前提供的 openjdk 我們可以在 GitHub 上找到它構建映象的 Dockerfile 可以看到其中的一些構建過程與我們前面所說的實踐方式相符。

我們選擇 Docker 官方 openjdk 映象來作為基礎映象,Dockerfile 可以改寫為:

FROM openjdk:17-jdk-bullseye

COPY target/gs-spring-boot-0.1.0.jar /app/

CMD [ "java", "-jar", "/app/gs-spring-boot-0.1.0.jar" ]

openjdk 有很多不同的 tag 比如 8-jdk-stretch 8-jre-stretch 以及 8-jre-alpine 之類的,具體的可以在 openjdk 的 tag 頁面檢視。

我們其實只想要一個 Java 的執行環境,所以可以選擇一個體積相對較小的映象 openjdk:17-jdk-slim-bullseye 這樣 Dockerfile 可以改寫為:

FROM openjdk:17-jdk-slim-bullseye

COPY target/gs-spring-boot-0.1.0.jar /app/

CMD [ "java", "-jar", "/app/gs-spring-boot-0.1.0.jar" ]

分別用上面的 Dockerfile 構建映象,可以看到映象大小

(MoeLove) ➜  docker image ls local/spring-boot                           
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
local/spring-boot   5-1                 b423dfc8d995        23 minutes ago      303MB
local/spring-boot   5                   7158d42a6a87        25 minutes ago      643MB
local/spring-boot   4-2                 ac272f3dcac2        4 hours ago         481MB
local/spring-boot   4                   716523c83a26        5 hours ago         497MB
local/spring-boot   2                   178dacdaf015        14 hours ago        600MB

很明顯,使用 openjdk:17-jdk-slim-bullseye 後,映象大小隻有 303M 比之前的映象小了很多。

第五個實踐指南: 儘可能選擇官方映象,看實際需求進行最終選擇 這樣說的原因,主要是因為有些映象是基於 Alpine Linux 的,Alpine 並非基於 glibc 的,而是基於 musl 的,如果是 Python 的專案,請實際測試下效能損失再決定是否選擇 Alpine Linux (這裡是我做的一份關於 Python 各映象主要的效能對比,有需要可以參考)

保持構建環境一致

在前面的實踐中,我們都是先本地構建好之後,才 COPY 進去的,這容易導致不同使用者構建出的映象可能不同。所以我們將構建過程寫入到 Dockerfile:

FROM maven:3.8.7-openjdk-18-slim

WORKDIR /app

COPY pom.xml /app/
COPY src /app/src

RUN mvn -e -B package

CMD [ "java", "-jar", "/app/target/gs-spring-boot-0.1.0.jar" ]

這樣所有人都可以使用相同的 Dockerfile 構建出相同的映象了。

但我們也會發現一個問題,在 mvn -e -B package 這一步耗費的時間特別長,因為它需要先拉取依賴才能進行構建。而對於專案開發而言,程式碼變更比依賴變更更加頻繁,為了能加快構建速度,有效的利用快取,我們將解決依賴與構建分成兩步。

FROM maven:3.8.7-openjdk-18-slim

WORKDIR /app

COPY pom.xml /app/
RUN mvn dependency:go-offline
COPY src /app/src

RUN mvn -e -B package

CMD [ "java", "-jar", "/app/target/gs-spring-boot-0.1.0.jar" ]

這樣, 即使業務程式碼發生改變,也不需要重新解決依賴,可有效的利用了快取,加快構建的速度

當然,現在我們構建的映象中,還是包含著專案的原始碼,這其實並非我們所需要的。那麼我們可以使用 多階段構建來解決這個問題。Dockerfile 可以修改為:

FROM maven:3.8.7-openjdk-18-slim AS builder

WORKDIR /app

COPY pom.xml /app/
RUN mvn dependency:go-offline
COPY src /app/src
RUN mvn -e -B package

FROM openjdk:17-jdk-slim-bullseye

COPY --from=builder /app/target/gs-spring-boot-0.1.0.jar /

CMD [ "java", "-jar", "/gs-spring-boot-0.1.0.jar" ]

當然,多階段構建也並不只是為了縮小映象體積;我們可以使用指定構建階段,以滿足多種不同的映象需求。

Dockerfile 可以修改為:

FROM maven:3.8.7-openjdk-18-slim AS builder

WORKDIR /app

COPY pom.xml /app/
RUN mvn dependency:go-offline
COPY src /app/src
RUN mvn -e -B package

FROM builder AS dev

RUN  apt-get update -y && apt-get install -y vim

FROM openjdk:17-jdk-slim-bullseye

COPY --from=builder /app/target/gs-spring-boot-0.1.0.jar /

CMD [ "java", "-jar", "/gs-spring-boot-0.1.0.jar" ]

我們可以使用如下的命令來構建不同階段的映象;

# 構建用於開發的映象
(MoeLove) ➜  docker build --target dev -t local/spring-boot:6-4-dev .    
# 構建用於生產部署的映象
(MoeLove) ➜  docker build -t local/spring-boot:6-4 .    

我們來看看在這個過程中映象大小的變化:

(MoeLove) ➜  docker image ls local/spring-boot                           
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
local/spring-boot   6-4-dev             f47a322c9de3        6 seconds ago       450MB
local/spring-boot   6-4                 2ab6215ff05e        3 minutes ago       303MB
local/spring-boot   6-3                 2ab6215ff05e        3 minutes ago       303MB
local/spring-boot   6-2                 2b3d3f923e05        4 minutes ago       325MB
local/spring-boot   6                   f96bea38825f        2 hours ago         388MB

第六個實踐指南: 可以利用多階段構建保持構建和執行環境的一致,也可以利用多階段構建來控制構建的目標階段。
這對於維護相對大型的專案是非常有幫助的,比如 Docker 專案自身的 Dockerfile 就充分的利用了多階段構建的特性。

如何提升構建效率

在構建 Docker 映象的最佳實踐部分中,我們提到了很多方法,比如利用快取;減少安裝依賴等,這些都可以提升構建效率。

我們還提到了多階段構建,這是一種很方便而且很靈活的方式。但多階段構建,在預設情況下是順序構建;

對於 18.09+ 版本,可以透過配置啟動 Buildkit 。對於新版本 v23.0.0 及 Docker Desktop 中都預設啟用了 Buildkit 。

我在之前的文章 萬字長文:徹底搞懂容器映象構建 | MoeLove
中也介紹了 Buildkit 和 Docker 原有的 builder 的區別及聯絡。

除此之外,還有很多其他的手段可以用於提升映象構建,或者說 CI/CD pipeline 的效率,我會在後續文章中繼續分享相關的經驗。

總結

本文深入介紹了 Docker 映象是什麼,容器和映象的區別,如何構建映象, 以及 6 個構建映象的最佳實踐。
事實上關於 Docker 映象構建在生產環境中的應用,我還有很多經驗可以分享,
我們下篇文章見!


歡迎訂閱我的文章公眾號【MoeLove】

相關文章