## 使用 Dockerfile 定制鏡像
從剛才的 `docker commit` 的學習中,我們可以了解到,鏡像的定制實際上就是定制每一層所添加的配置、文件。如果我們可以把每一層修改、安裝、構建、操作的命令都寫入一個腳本,用這個腳本來構建、定制鏡像,那么之前提及的無法重復的問題、鏡像構建透明性的問題、體積的問題就都會解決。這個腳本就是 Dockerfile。
Dockerfile 是一個文本文件,其內包含了一條條的**指令(Instruction)**,每一條指令構建一層,因此每一條指令的內容,就是描述該層應當如何構建。
還以之前定制 `nginx` 鏡像為例,這次我們使用 Dockerfile 來定制。
在一個空白目錄中,建立一個文本文件,并命名為 `Dockerfile`:
```bash
$ mkdir mynginx
$ cd mynginx
$ touch Dockerfile
```
其內容為:
```dockerfile
FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
```
這個 Dockerfile 很簡單,一共就兩行。涉及到了兩條指令,`FROM` 和 `RUN`。
### FROM 指定基礎鏡像
所謂定制鏡像,那一定是以一個鏡像為基礎,在其上進行定制。就像我們之前運行了一個 `nginx` 鏡像的容器,再進行修改一樣,基礎鏡像是必須指定的。而 `FROM` 就是指定**基礎鏡像**,因此一個 `Dockerfile` 中 `FROM` 是必備的指令,并且必須是第一條指令。
在 [Docker Store](https://store.docker.com) 上有非常多的高質量的官方鏡像,有可以直接拿來使用的服務類的鏡像,如 [`nginx`](https://store.docker.com/images/nginx/)、[`redis`](https://store.docker.com/images/redis/)、[`mongo`](https://store.docker.com/images/mongo/)、[`mysql`](https://store.docker.com/images/mysql/)、[`httpd`](https://store.docker.com/images/httpd/)、[`php`](https://store.docker.com/images/php/)、[`tomcat`](https://store.docker.com/images/tomcat/) 等;也有一些方便開發、構建、運行各種語言應用的鏡像,如 [`node`](https://store.docker.com/images/node)、[`openjdk`](https://store.docker.com/images/openjdk/)、[`python`](https://store.docker.com/images/python/)、[`ruby`](https://store.docker.com/images/ruby/)、[`golang`](https://store.docker.com/images/golang/) 等。可以在其中尋找一個最符合我們最終目標的鏡像為基礎鏡像進行定制。
如果沒有找到對應服務的鏡像,官方鏡像中還提供了一些更為基礎的操作系統鏡像,如 [`ubuntu`](https://store.docker.com/images/ubuntu/)、[`debian`](https://store.docker.com/images/debian/)、[`centos`](https://store.docker.com/images/centos/)、[`fedora`](https://store.docker.com/images/fedora/)、[`alpine`](https://store.docker.com/images/alpine/) 等,這些操作系統的軟件庫為我們提供了更廣闊的擴展空間。
除了選擇現有鏡像為基礎鏡像外,Docker 還存在一個特殊的鏡像,名為 `scratch`。這個鏡像是虛擬的概念,并不實際存在,它表示一個空白的鏡像。
```dockerfile
FROM scratch
...
```
如果你以 `scratch` 為基礎鏡像的話,意味著你不以任何鏡像為基礎,接下來所寫的指令將作為鏡像第一層開始存在。
不以任何系統為基礎,直接將可執行文件復制進鏡像的做法并不罕見,比如 [`swarm`](https://hub.docker.com/_/swarm/)、[`coreos/etcd`](https://quay.io/repository/coreos/etcd)。對于 Linux 下靜態編譯的程序來說,并不需要有操作系統提供運行時支持,所需的一切庫都已經在可執行文件里了,因此直接 `FROM scratch` 會讓鏡像體積更加小巧。使用 [Go 語言](https://golang.org/) 開發的應用很多會使用這種方式來制作鏡像,這也是為什么有人認為 Go 是特別適合容器微服務架構的語言的原因之一。
### RUN 執行命令
`RUN` 指令是用來執行命令行命令的。由于命令行的強大能力,`RUN` 指令在定制鏡像時是最常用的指令之一。其格式有兩種:
* *shell* 格式:`RUN <命令>`,就像直接在命令行中輸入的命令一樣。剛才寫的 Dockerfile 中的 `RUN` 指令就是這種格式。
```Dockerfile
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
```
* *exec* 格式:`RUN ["可執行文件", "參數1", "參數2"]`,這更像是函數調用中的格式。
既然 `RUN` 就像 Shell 腳本一樣可以執行命令,那么我們是否就可以像 Shell 腳本一樣把每個命令對應一個 RUN 呢?比如這樣:
```dockerfile
FROM debian:jessie
RUN apt-get update
RUN apt-get install -y gcc libc6-dev make
RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz"
RUN mkdir -p /usr/src/redis
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
RUN make -C /usr/src/redis
RUN make -C /usr/src/redis install
```
之前說過,Dockerfile 中每一個指令都會建立一層,`RUN` 也不例外。每一個 `RUN` 的行為,就和剛才我們手工建立鏡像的過程一樣:新建立一層,在其上執行這些命令,執行結束后,`commit` 這一層的修改,構成新的鏡像。
而上面的這種寫法,創建了 7 層鏡像。這是完全沒有意義的,而且很多運行時不需要的東西,都被裝進了鏡像里,比如編譯環境、更新的軟件包等等。結果就是產生非常臃腫、非常多層的鏡像,不僅僅增加了構建部署的時間,也很容易出錯。
這是很多初學 Docker 的人常犯的一個錯誤。
*Union FS 是有最大層數限制的,比如 AUFS,曾經是最大不得超過 42 層,現在是不得超過 127 層。*
上面的 `Dockerfile` 正確的寫法應該是這樣:
```dockerfile
FROM debian:jessie
RUN buildDeps='gcc libc6-dev make' \
&& apt-get update \
&& apt-get install -y $buildDeps \
&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz" \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -rf /var/lib/apt/lists/* \
&& rm redis.tar.gz \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps
```
首先,之前所有的命令只有一個目的,就是編譯、安裝 redis 可執行文件。因此沒有必要建立很多層,這只是一層的事情。因此,這里沒有使用很多個 `RUN` 對一一對應不同的命令,而是僅僅使用一個 `RUN` 指令,并使用 `&&` 將各個所需命令串聯起來。將之前的 7 層,簡化為了 1 層。在撰寫 Dockerfile 的時候,要經常提醒自己,這并不是在寫 Shell 腳本,而是在定義每一層該如何構建。
并且,這里為了格式化還進行了換行。Dockerfile 支持 Shell 類的行尾添加 `\` 的命令換行方式,以及行首 `#` 進行注釋的格式。良好的格式,比如換行、縮進、注釋等,會讓維護、排障更為容易,這是一個比較好的習慣。
此外,還可以看到這一組命令的最后添加了清理工作的命令,刪除了為了編譯構建所需要的軟件,清理了所有下載、展開的文件,并且還清理了 `apt` 緩存文件。這是很重要的一步,我們之前說過,鏡像是多層存儲,每一層的東西并不會在下一層被刪除,會一直跟隨著鏡像。因此鏡像構建時,一定要確保每一層只添加真正需要添加的東西,任何無關的東西都應該清理掉。
很多人初學 Docker 制作出了很臃腫的鏡像的原因之一,就是忘記了每一層構建的最后一定要清理掉無關文件。
### 構建鏡像
好了,讓我們再回到之前定制的 nginx 鏡像的 Dockerfile 來。現在我們明白了這個 Dockerfile 的內容,那么讓我們來構建這個鏡像吧。
在 `Dockerfile` 文件所在目錄執行:
```bash
$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM nginx
---> e43d811ce2f4
Step 2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
---> Running in 9cdc27646c7b
---> 44aa4490ce2c
Removing intermediate container 9cdc27646c7b
Successfully built 44aa4490ce2c
```
從命令的輸出結果中,我們可以清晰的看到鏡像的構建過程。在 `Step 2` 中,如同我們之前所說的那樣,`RUN` 指令啟動了一個容器 `9cdc27646c7b`,執行了所要求的命令,并最后提交了這一層 `44aa4490ce2c`,隨后刪除了所用到的這個容器 `9cdc27646c7b`。
這里我們使用了 `docker build` 命令進行鏡像構建。其格式為:
```bash
docker build [選項] <上下文路徑/URL/->
```
在這里我們指定了最終鏡像的名稱 `-t nginx:v3`,構建成功后,我們可以像之前運行 `nginx:v2` 那樣來運行這個鏡像,其結果會和 `nginx:v2` 一樣。
### 鏡像構建上下文(Context)
如果注意,會看到 `docker build` 命令最后有一個 `.`。`.` 表示當前目錄,而 `Dockerfile` 就在當前目錄,因此不少初學者以為這個路徑是在指定 `Dockerfile` 所在路徑,這么理解其實是不準確的。如果對應上面的命令格式,你可能會發現,這是在指定**上下文路徑**。那么什么是上下文呢?
首先我們要理解 `docker build` 的工作原理。Docker 在運行時分為 Docker 引擎(也就是服務端守護進程)和客戶端工具。Docker 的引擎提供了一組 REST API,被稱為 [Docker Remote API](https://docs.docker.com/engine/reference/api/docker_remote_api/),而如 `docker` 命令這樣的客戶端工具,則是通過這組 API 與 Docker 引擎交互,從而完成各種功能。因此,雖然表面上我們好像是在本機執行各種 `docker` 功能,但實際上,一切都是使用的遠程調用形式在服務端(Docker 引擎)完成。也因為這種 C/S 設計,讓我們操作遠程服務器的 Docker 引擎變得輕而易舉。
當我們進行鏡像構建的時候,并非所有定制都會通過 `RUN` 指令完成,經常會需要將一些本地文件復制進鏡像,比如通過 `COPY` 指令、`ADD` 指令等。而 `docker build` 命令構建鏡像,其實并非在本地構建,而是在服務端,也就是 Docker 引擎中構建的。那么在這種客戶端/服務端的架構中,如何才能讓服務端獲得本地文件呢?
這就引入了上下文的概念。當構建的時候,用戶會指定構建鏡像上下文的路徑,`docker build` 命令得知這個路徑后,會將路徑下的所有內容打包,然后上傳給 Docker 引擎。這樣 Docker 引擎收到這個上下文包后,展開就會獲得構建鏡像所需的一切文件。
如果在 `Dockerfile` 中這么寫:
```Dockerfile
COPY ./package.json /app/
```
這并不是要復制執行 `docker build` 命令所在的目錄下的 `package.json`,也不是復制 `Dockerfile` 所在目錄下的 `package.json`,而是復制 **上下文(context)** 目錄下的 `package.json`。
因此,`COPY` 這類指令中的源文件的路徑都是*相對路徑*。這也是初學者經常會問的為什么 `COPY ../package.json /app` 或者 `COPY /opt/xxxx /app` 無法工作的原因,因為這些路徑已經超出了上下文的范圍,Docker 引擎無法獲得這些位置的文件。如果真的需要那些文件,應該將它們復制到上下文目錄中去。
現在就可以理解剛才的命令 `docker build -t nginx:v3 .` 中的這個 `.`,實際上是在指定上下文的目錄,`docker build` 命令會將該目錄下的內容打包交給 Docker 引擎以幫助構建鏡像。
如果觀察 `docker build` 輸出,我們其實已經看到了這個發送上下文的過程:
```bash
$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
...
```
理解構建上下文對于鏡像構建是很重要的,避免犯一些不應該的錯誤。比如有些初學者在發現 `COPY /opt/xxxx /app` 不工作后,于是干脆將 `Dockerfile` 放到了硬盤根目錄去構建,結果發現 `docker build` 執行后,在發送一個幾十 GB 的東西,極為緩慢而且很容易構建失敗。那是因為這種做法是在讓 `docker build` 打包整個硬盤,這顯然是使用錯誤。
一般來說,應該會將 `Dockerfile` 置于一個空目錄下,或者項目根目錄下。如果該目錄下沒有所需文件,那么應該把所需文件復制一份過來。如果目錄下有些東西確實不希望構建時傳給 Docker 引擎,那么可以用 `.gitignore` 一樣的語法寫一個 `.dockerignore`,該文件是用于剔除不需要作為上下文傳遞給 Docker 引擎的。
那么為什么會有人誤以為 `.` 是指定 `Dockerfile` 所在目錄呢?這是因為在默認情況下,如果不額外指定 `Dockerfile` 的話,會將上下文目錄下的名為 `Dockerfile` 的文件作為 Dockerfile。
這只是默認行為,實際上 `Dockerfile` 的文件名并不要求必須為 `Dockerfile`,而且并不要求必須位于上下文目錄中,比如可以用 `-f ../Dockerfile.php` 參數指定某個文件作為 `Dockerfile`。
當然,一般大家習慣性的會使用默認的文件名 `Dockerfile`,以及會將其置于鏡像構建上下文目錄中。
### 其它 `docker build` 的用法
#### 直接用 Git repo 進行構建
或許你已經注意到了,`docker build` 還支持從 URL 構建,比如可以直接從 Git repo 中構建:
```bash
$ docker build https://github.com/twang2218/gitlab-ce-zh.git#:8.14
docker build https://github.com/twang2218/gitlab-ce-zh.git\#:8.14
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM gitlab/gitlab-ce:8.14.0-ce.0
8.14.0-ce.0: Pulling from gitlab/gitlab-ce
aed15891ba52: Already exists
773ae8583d14: Already exists
...
```
這行命令指定了構建所需的 Git repo,并且指定默認的 `master` 分支,構建目錄為 `/8.14/`,然后 Docker 就會自己去 `git clone` 這個項目、切換到指定分支、并進入到指定目錄后開始構建。
#### 用給定的 tar 壓縮包構建
```bash
$ docker build http://server/context.tar.gz
```
如果所給出的 URL 不是個 Git repo,而是個 `tar` 壓縮包,那么 Docker 引擎會下載這個包,并自動解壓縮,以其作為上下文,開始構建。
#### 從標準輸入中讀取 Dockerfile 進行構建
```bash
docker build - < Dockerfile
```
或
```bash
cat Dockerfile | docker build -
```
如果標準輸入傳入的是文本文件,則將其視為 `Dockerfile`,并開始構建。這種形式由于直接從標準輸入中讀取 Dockerfile 的內容,它沒有上下文,因此不可以像其他方法那樣可以將本地文件 `COPY` 進鏡像之類的事情。
#### 從標準輸入中讀取上下文壓縮包進行構建
```bash
$ docker build - < context.tar.gz
```
如果發現標準輸入的文件格式是 `gzip`、`bzip2` 以及 `xz` 的話,將會使其為上下文壓縮包,直接將其展開,將里面視為上下文,并開始構建。
- 前言
- 修訂記錄
- 如何貢獻
- 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 中文資源