# Dockerfile 最佳實踐
本附錄是筆者對 Docker 官方文檔中 [Best practices for writing Dockerfiles](https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/) 的理解與翻譯。
## 一般性的指南和建議
### 容器應該是短暫的
通過 `Dockerfile` 構建的鏡像所啟動的容器應該盡可能短暫(生命周期短)。「短暫」意味著可以停止和銷毀容器,并且創建一個新容器并部署好所需的設置和配置工作量應該是極小的。
### 使用 `.dockerignore` 文件
使用 `Dockerfile` 構建鏡像時最好是將 `Dockerfile` 放置在一個新建的空目錄下。然后將構建鏡像所需要的文件添加到該目錄中。為了提高構建鏡像的效率,你可以在目錄下新建一個 `.dockerignore` 文件來指定要忽略的文件和目錄。`.dockerignore` 文件的排除模式語法和 Git 的 `.gitignore` 文件相似。
### 使用多階段構建
在 `Docker 17.05` 以上版本中,你可以使用 [多階段構建](../image/multistage-builds.md) 來減少所構建鏡像的大小。
### 避免安裝不必要的包
為了降低復雜性、減少依賴、減小文件大小、節約構建時間,你應該避免安裝任何不必要的包。例如,不要在數據庫鏡像中包含一個文本編輯器。
### 一個容器只運行一個進程
應該保證在一個容器中只運行一個進程。將多個應用解耦到不同容器中,保證了容器的橫向擴展和復用。例如 web 應用應該包含三個容器:web應用、數據庫、緩存。
如果容器互相依賴,你可以使用 [Docker 自定義網絡](../network/linking.md) 來把這些容器連接起來。
### 鏡像層數盡可能少
你需要在 `Dockerfile` 可讀性(也包括長期的可維護性)和減少層數之間做一個平衡。
### 將多行參數排序
將多行參數按字母順序排序(比如要安裝多個包時)。這可以幫助你避免重復包含同一個包,更新包列表時也更容易。也便于 `PRs` 閱讀和審查。建議在反斜杠符號 `\` 之前添加一個空格,以增加可讀性。
下面是來自 `buildpack-deps` 鏡像的例子:
```docker
RUN apt-get update && apt-get install -y \
bzr \
cvs \
git \
mercurial \
subversion
```
### 構建緩存
在鏡像的構建過程中,Docker 會遍歷 `Dockerfile` 文件中的指令,然后按順序執行。在執行每條指令之前,Docker 都會在緩存中查找是否已經存在可重用的鏡像,如果有就使用現存的鏡像,不再重復創建。如果你不想在構建過程中使用緩存,你可以在 `docker build` 命令中使用 `--no-cache=true` 選項。
但是,如果你想在構建的過程中使用緩存,你得明白什么時候會,什么時候不會找到匹配的鏡像,遵循的基本規則如下:
* 從一個基礎鏡像開始(`FROM` 指令指定),下一條指令將和該基礎鏡像的所有子鏡像進行匹配,檢查這些子鏡像被創建時使用的指令是否和被檢查的指令完全一樣。如果不是,則緩存失效。
* 在大多數情況下,只需要簡單地對比 `Dockerfile` 中的指令和子鏡像。然而,有些指令需要更多的檢查和解釋。
* 對于 `ADD` 和 `COPY` 指令,鏡像中對應文件的內容也會被檢查,每個文件都會計算出一個校驗和。文件的最后修改時間和最后訪問時間不會納入校驗。在緩存的查找過程中,會將這些校驗和和已存在鏡像中的文件校驗和進行對比。如果文件有任何改變,比如內容和元數據,則緩存失效。
* 除了 `ADD` 和 `COPY` 指令,緩存匹配過程不會查看臨時容器中的文件來決定緩存是否匹配。例如,當執行完 `RUN apt-get -y update` 指令后,容器中一些文件被更新,但 Docker 不會檢查這些文件。這種情況下,只有指令字符串本身被用來匹配緩存。
一旦緩存失效,所有后續的 `Dockerfile` 指令都將產生新的鏡像,緩存不會被使用。
## Dockerfile 指令
下面針對 `Dockerfile` 中各種指令的最佳編寫方式給出建議。
### FROM
盡可能使用當前官方倉庫作為你構建鏡像的基礎。推薦使用 [Alpine](https://hub.docker.com/_/alpine/) 鏡像,因為它被嚴格控制并保持最小尺寸(目前小于 5 MB),但它仍然是一個完整的發行版。
### LABEL
你可以給鏡像添加標簽來幫助組織鏡像、記錄許可信息、輔助自動化構建等。每個標簽一行,由 `LABEL` 開頭加上一個或多個標簽對。下面的示例展示了各種不同的可能格式。`#` 開頭的行是注釋內容。
>注意:如果你的字符串中包含空格,必須將字符串放入引號中或者對空格使用轉義。如果字符串內容本身就包含引號,必須對引號使用轉義。
```docker
# Set one or more individual labels
LABEL com.example.version="0.0.1-beta"
LABEL vendor="ACME Incorporated"
LABEL com.example.release-date="2015-02-12"
LABEL com.example.version.is-production=""
```
一個鏡像可以包含多個標簽,但建議將多個標簽放入到一個 `LABEL` 指令中。
```docker
# Set multiple labels at once, using line-continuation characters to break long lines
LABEL vendor=ACME\ Incorporated \
com.example.is-beta= \
com.example.is-production="" \
com.example.version="0.0.1-beta" \
com.example.release-date="2015-02-12"
```
關于標簽可以接受的鍵值對,參考 [Understanding object labels](https://docs.docker.com/engine/userguide/labels-custom-metadata/)。關于查詢標簽信息,參考 [Managing labels on objects](https://docs.docker.com/engine/userguide/labels-custom-metadata/#managing-labels-on-objects)。
### RUN
為了保持 `Dockerfile` 文件的可讀性,可理解性,以及可維護性,建議將長的或復雜的 `RUN` 指令用反斜杠 `\` 分割成多行。
#### apt-get
`RUN` 指令最常見的用法是安裝包用的 `apt-get`。因為 `RUN apt-get` 指令會安裝包,所以有幾個問題需要注意。
不要使用 `RUN apt-get upgrade` 或 `dist-upgrade`,因為許多基礎鏡像中的「必須」包不會在一個非特權容器中升級。如果基礎鏡像中的某個包過時了,你應該聯系它的維護者。如果你確定某個特定的包,比如 `foo`,需要升級,使用 `apt-get install -y foo` 就行,該指令會自動升級 `foo` 包。
永遠將 `RUN apt-get update` 和 `apt-get install` 組合成一條 `RUN` 聲明,例如:
```docker
RUN apt-get update && apt-get install -y \
package-bar \
package-baz \
package-foo
```
將 `apt-get update` 放在一條單獨的 `RUN` 聲明中會導致緩存問題以及后續的 `apt-get install` 失敗。比如,假設你有一個 `Dockerfile` 文件:
```docker
FROM ubuntu:14.04
RUN apt-get update
RUN apt-get install -y curl
```
構建鏡像后,所有的層都在 Docker 的緩存中。假設你后來又修改了其中的 `apt-get install` 添加了一個包:
```docker
FROM ubuntu:14.04
RUN apt-get update
RUN apt-get install -y curl nginx
```
Docker 發現修改后的 `RUN apt-get update` 指令和之前的完全一樣。所以,`apt-get update` 不會執行,而是使用之前的緩存鏡像。因為 `apt-get update` 沒有運行,后面的 `apt-get install` 可能安裝的是過時的 `curl` 和 `nginx` 版本。
使用 `RUN apt-get update && apt-get install -y` 可以確保你的 Dockerfiles 每次安裝的都是包的最新的版本,而且這個過程不需要進一步的編碼或額外干預。這項技術叫作 `cache busting`。你也可以顯示指定一個包的版本號來達到 `cache-busting`,這就是所謂的固定版本,例如:
```docker
RUN apt-get update && apt-get install -y \
package-bar \
package-baz \
package-foo=1.3.*
```
固定版本會迫使構建過程檢索特定的版本,而不管緩存中有什么。這項技術也可以減少因所需包中未預料到的變化而導致的失敗。
下面是一個 `RUN` 指令的示例模板,展示了所有關于 `apt-get` 的建議。
```docker
RUN apt-get update && apt-get install -y \
aufs-tools \
automake \
build-essential \
curl \
dpkg-sig \
libcap-dev \
libsqlite3-dev \
mercurial \
reprepro \
ruby1.9.1 \
ruby1.9.1-dev \
s3cmd=1.1.* \
&& rm -rf /var/lib/apt/lists/*
```
其中 `s3cmd` 指令指定了一個版本號 `1.1.*`。如果之前的鏡像使用的是更舊的版本,指定新的版本會導致 `apt-get udpate` 緩存失效并確保安裝的是新版本。
另外,清理掉 apt 緩存 `var/lib/apt/lists` 可以減小鏡像大小。因為 `RUN` 指令的開頭為 `apt-get udpate`,包緩存總是會在 `apt-get install` 之前刷新。
> 注意:官方的 Debian 和 Ubuntu 鏡像會自動運行 apt-get clean,所以不需要顯式的調用 apt-get clean。
### CMD
`CMD` 指令用于執行目標鏡像中包含的軟件,可以包含參數。`CMD` 大多數情況下都應該以 `CMD ["executable", "param1", "param2"...]` 的形式使用。因此,如果創建鏡像的目的是為了部署某個服務(比如 `Apache`),你可能會執行類似于 `CMD ["apache2", "-DFOREGROUND"]` 形式的命令。我們建議任何服務鏡像都使用這種形式的命令。
多數情況下,`CMD` 都需要一個交互式的 `shell` (bash, Python, perl 等),例如 `CMD ["perl", "-de0"]`,或者 `CMD ["PHP", "-a"]`。使用這種形式意味著,當你執行類似 `docker run -it python` 時,你會進入一個準備好的 `shell` 中。`CMD` 應該在極少的情況下才能以 `CMD ["param", "param"]` 的形式與 `ENTRYPOINT` 協同使用,除非你和你的鏡像使用者都對 `ENTRYPOINT` 的工作方式十分熟悉。
### EXPOSE
`EXPOSE` 指令用于指定容器將要監聽的端口。因此,你應該為你的應用程序使用常見的端口。例如,提供 `Apache` web 服務的鏡像應該使用 `EXPOSE 80`,而提供 `MongoDB` 服務的鏡像使用 `EXPOSE 27017`。
對于外部訪問,用戶可以在執行 `docker run` 時使用一個標志來指示如何將指定的端口映射到所選擇的端口。
### ENV
為了方便新程序運行,你可以使用 `ENV` 來為容器中安裝的程序更新 `PATH` 環境變量。例如使用 `ENV PATH /usr/local/nginx/bin:$PATH` 來確保 `CMD ["nginx"]` 能正確運行。
`ENV` 指令也可用于為你想要容器化的服務提供必要的環境變量,比如 Postgres 需要的 `PGDATA`。
最后,`ENV` 也能用于設置常見的版本號,比如下面的示例:
```docker
ENV PG_MAJOR 9.3
ENV PG_VERSION 9.3.4
RUN curl -SL http://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgress && …
ENV PATH /usr/local/postgres-$PG_MAJOR/bin:$PATH
```
類似于程序中的常量,這種方法可以讓你只需改變 `ENV` 指令來自動的改變容器中的軟件版本。
### ADD 和 COPY
雖然 `ADD` 和 `COPY` 功能類似,但一般優先使用 `COPY`。因為它比 `ADD` 更透明。`COPY` 只支持簡單將本地文件拷貝到容器中,而 `ADD` 有一些并不明顯的功能(比如本地 tar 提取和遠程 URL 支持)。因此,`ADD` 的最佳用例是將本地 tar 文件自動提取到鏡像中,例如 `ADD rootfs.tar.xz`。
如果你的 `Dockerfile` 有多個步驟需要使用上下文中不同的文件。單獨 `COPY` 每個文件,而不是一次性的 `COPY` 所有文件,這將保證每個步驟的構建緩存只在特定的文件變化時失效。例如:
```docker
COPY requirements.txt /tmp/
RUN pip install --requirement /tmp/requirements.txt
COPY . /tmp/
```
如果將 `COPY . /tmp/` 放置在 `RUN` 指令之前,只要 `.` 目錄中任何一個文件變化,都會導致后續指令的緩存失效。
為了讓鏡像盡量小,最好不要使用 `ADD` 指令從遠程 URL 獲取包,而是使用 `curl` 和 `wget`。這樣你可以在文件提取完之后刪掉不再需要的文件來避免在鏡像中額外添加一層。比如盡量避免下面的用法:
```docker
ADD http://example.com/big.tar.xz /usr/src/things/
RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things
RUN make -C /usr/src/things all
```
而是應該使用下面這種方法:
```docker
RUN mkdir -p /usr/src/things \
&& curl -SL http://example.com/big.tar.xz \
| tar -xJC /usr/src/things \
&& make -C /usr/src/things all
```
上面使用的管道操作,所以沒有中間文件需要刪除。
對于其他不需要 `ADD` 的自動提取功能的文件或目錄,你應該使用 `COPY`。
### ENTRYPOINT
`ENTRYPOINT` 的最佳用處是設置鏡像的主命令,允許將鏡像當成命令本身來運行(用 `CMD` 提供默認選項)。
例如,下面的示例鏡像提供了命令行工具 `s3cmd`:
```docker
ENTRYPOINT ["s3cmd"]
CMD ["--help"]
```
現在直接運行該鏡像創建的容器會顯示命令幫助:
```bash
$ docker run s3cmd
```
或者提供正確的參數來執行某個命令:
```bash
$ docker run s3cmd ls s3://mybucket
```
這樣鏡像名可以當成命令行的參考。
`ENTRYPOINT` 指令也可以結合一個輔助腳本使用,和前面命令行風格類似,即使啟動工具需要不止一個步驟。
例如,`Postgres` 官方鏡像使用下面的腳本作為 `ENTRYPOINT`:
```bash
#!/bin/bash
set -e
if [ "$1" = 'postgres' ]; then
chown -R postgres "$PGDATA"
if [ -z "$(ls -A "$PGDATA")" ]; then
gosu postgres initdb
fi
exec gosu postgres "$@"
fi
exec "$@"
```
>注意:該腳本使用了 Bash 的內置命令 exec,所以最后運行的進程就是容器的 PID 為 1 的進程。這樣,進程就可以接收到任何發送給容器的 Unix 信號了。
該輔助腳本被拷貝到容器,并在容器啟動時通過 `ENTRYPOINT` 執行:
```docker
COPY ./docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
```
該腳本可以讓用戶用幾種不同的方式和 `Postgres` 交互。
你可以很簡單地啟動 `Postgres`:
```bash
$ docker run postgres
```
也可以執行 `Postgres` 并傳遞參數:
```bash
$ docker run postgres postgres --help
```
最后,你還可以啟動另外一個完全不同的工具,比如 `Bash`:
```bash
$ docker run --rm -it postgres bash
```
### VOLUME
`VOLUME` 指令用于暴露任何數據庫存儲文件,配置文件,或容器創建的文件和目錄。強烈建議使用 `VOLUME` 來管理鏡像中的可變部分和用戶可以改變的部分。
### USER
如果某個服務不需要特權執行,建議使用 `USER` 指令切換到非 root 用戶。先在 `Dockerfile` 中使用類似 `RUN groupadd -r postgres && useradd -r -g postgres postgres` 的指令創建用戶和用戶組。
>注意:在鏡像中,用戶和用戶組每次被分配的 UID/GID 都是不確定的,下次重新構建鏡像時被分配到的 UID/GID 可能會不一樣。如果要依賴確定的 UID/GID,你應該顯示的指定一個 UID/GID。
你應該避免使用 `sudo`,因為它不可預期的 TTY 和信號轉發行為可能造成的問題比它能解決的問題還多。如果你真的需要和 `sudo` 類似的功能(例如,以 root 權限初始化某個守護進程,以非 root 權限執行它),你可以使用 [gosu](https://github.com/tianon/gosu)。
最后,為了減少層數和復雜度,避免頻繁地使用 `USER` 來回切換用戶。
### WORKDIR
為了清晰性和可靠性,你應該總是在 `WORKDIR` 中使用絕對路徑。另外,你應該使用 `WORKDIR` 來替代類似于 `RUN cd ... && do-something` 的指令,后者難以閱讀、排錯和維護。
## 官方倉庫示例
這些官方倉庫的 Dockerfile 都是參考典范:https://github.com/docker-library/docs
- 前言
- 修訂記錄
- 如何貢獻
- Docker 簡介
- 什么是 Docker
- 為什么要用 Docker
- 基本概念
- 鏡像
- 容器
- 倉庫
- 安裝 Docker
- Ubuntu
- Debian
- CentOS
- Raspberry Pi
- macOS
- Windows PC
- 鏡像加速器
- 使用鏡像
- 獲取鏡像
- 列出鏡像
- 刪除本地鏡像
- 利用 commit 理解鏡像構成
- 使用 Dockerfile 定制鏡像
- Dockerfile 指令詳解
- COPY 復制文件
- ADD 更高級的復制文件
- CMD 容器啟動命令
- ENTRYPOINT 入口點
- ENV 設置環境變量
- ARG 構建參數
- VOLUME 定義匿名卷
- EXPOSE 暴露端口
- WORKDIR 指定工作目錄
- USER 指定當前用戶
- HEALTHCHECK 健康檢查
- ONBUILD 為他人作嫁衣裳
- 參考文檔
- Dockerfile 多階段構建
- 其它制作鏡像的方式
- 實現原理
- 操作容器
- 啟動
- 守護態運行
- 終止
- 進入容器
- 導出和導入
- 刪除
- 訪問倉庫
- Docker Hub
- 私有倉庫
- 私有倉庫高級配置
- Nexus 3
- 數據管理
- 數據卷
- 掛載主機目錄
- 使用網絡
- 外部訪問容器
- 容器互聯
- 配置 DNS
- 高級網絡配置
- 快速配置指南
- 容器訪問控制
- 端口映射實現
- 配置 docker0 網橋
- 自定義網橋
- 工具和示例
- 編輯網絡配置文件
- 實例:創建一個點到點連接
- Docker 三劍客之 Compose 項目
- 簡介
- 安裝與卸載
- 使用
- 命令說明
- Compose 模板文件
- 實戰 Django
- 實戰 Rails
- 實戰 WordPress
- Docker 三劍客之 Machine 項目
- 安裝
- 使用
- Docker 三劍客之 Docker Swarm
- Swarm mode
- 基本概念
- 創建 Swarm 集群
- 部署服務
- 使用 compose 文件
- 管理敏感數據
- 管理配置信息
- 滾動升級
- 安全
- 內核命名空間
- 控制組
- 服務端防護
- 內核能力機制
- 其它安全特性
- 總結
- 底層實現
- 基本架構
- 命名空間
- 控制組
- 聯合文件系統
- 容器格式
- 網絡
- Etcd 項目
- 簡介
- 安裝
- 集群
- 使用 etcdctl
- CoreOS 項目
- 簡介
- 工具
- 快速搭建 CoreOS 集群
- Kubernetes 項目
- 簡介
- 快速上手
- 基本概念
- kubectl 使用
- 架構設計
- Mesos - 優秀的集群資源調度平臺
- Mesos 簡介
- 安裝與使用
- 原理與架構
- Mesos 配置項解析
- 日志與監控
- 常見應用框架
- 本章小結
- 容器與云計算
- 簡介
- 亞馬遜云
- 騰訊云
- 阿里云
- 小結
- 實戰案例-操作系統
- Busybox
- Alpine
- Debian Ubuntu
- CentOS Fedora
- 本章小結
- 實戰案例-CI/CD
- Drone
- Docker 開源項目
- LinuxKit
- 附錄
- 附錄一:常見問題總結
- 附錄二:熱門鏡像介紹
- Ubuntu
- CentOS
- MySQL
- MongoDB
- Redis
- Nginx
- WordPress
- Node.js
- 附錄三:Docker 命令查詢
- 附錄四:Dockerfile 最佳實踐
- 附錄五:資源鏈接
- 附錄六:Docker 中文資源