---
title: Docker镜像的构建
date: 2021-04-09 17:01:07
tags: [DevOps, Docker, 容器化]
categories:
  - [DevOps, Docker]
---
Docker镜像的构建是通过Dockerfile来完成的，一个Dockerfile中通过一组指令来完成Docker镜像的构建。
<!-- more -->
Dockerfile一般放置在项目的根目录中，文件名就命名为`Dockerfile`，并且Dockerfile中的指令一般都使用大写字母来书写。

Docker镜像是采用的是分层文件系统，文件系统的任何改动都发生在最顶层，其下的各层都是只读的。以下是一个Docker镜像的分层文件系统的示意。

{% asset_img docker_layerfs.png Docker Layered Filesystem %}

所以对于镜像的构建，实际上就是在形成要部署的应用所需的运行环境，而这个运行环境就是像上图一样，一层一层叠加起来的。

## 基本构建指令

### 声明基础镜像

一个Docker容器实际上与一个虚拟机是十分类似的，都需要一个操作系统作为最底层。但是最底层的镜像不一定必须是一个操作系统镜像，如Ubuntu、Debian、Alpine等，也可以是基于操作系统构建好一些基础环境的镜像，例如nodeJS镜像、JDK镜像、Python镜像等等。

声明一个基础镜像可以大大简化镜像的构建，尤其是可以简化许多重复性的构建。在Dockerfile中声明使用一个基础镜像使用`FROM`指令。`FROM`指令有以下两种格式。

- `FROM <image>[:<tag>] [AS <name>]`
- `FROM <image>[@<digest>] [AS <name>]`

这里最常用的格式是第一种，所需的镜像可以从Docker Hub上找到。当镜像的tag没有被指定的时候，Docker会默认去拉取标签为`latest`的镜像。但是在一般的镜像构建中并不推荐这种总是使用最新镜像的行为，所以通常都会指定一个具体版本的镜像来使用。例如：`FROM python:3.8-slim-buster`。

`AS`指令可以为基于当前基础镜像的构建命名，这个名称可以在后面的多级构建中使用，也就是一个构建可以从其之前的构建中获取一些内容。例如利用多级构建Angular应用的时候，可以使用`FROM node:12-buster-slim AS builder`来进行项目的编译工作，但不形成最终的部署镜像。

在Dockerfile的第一个`FORM`指令之前，可以使用`ARG`指令声明变量，例如：

```dockerfile
ARG version=latest
FROM ubuntu:${version}
```

通过`ARG`设置的变量，可以在编译镜像的时候使用参数`--build-arg`来重新设置这些定义的变量的值。需要注意的是，`ARG`设定的是镜像构建时使用的参数，`ENV`设定的是镜像创建出来的容器中的环境变量，不要混了。

### 指定默认用户

Docker容器中的应用都需要运行在一个系统账户里，这个系统账户是容器系统中的账户，不是宿主系统中的账户，指令`USER`可以用来设定镜像运行和构建镜像时`CMD`、`ENTRYPOINT`和`RUN`指令时所使用的用户。`USER`指令的格式有以下两种，基本上与Linux中的用户表示方法一致。

- `USER <user>[:<group>]`
- `USER <uid>[:<gid>]`

使用Linux作为基础镜像的时候，`USER`指令可以出现在任何位置，但是如果使用Windows作为基础镜像的时候，如果使用的不是内置账户，就必须先去创建一个新的用户。例如试试Windows作为基础镜像，设定用户就要如下面这样。
```dockerfile
FROM microsoft/windowsservercore
RUN net user /add anewuser
USER anewuser
```

### 运行指令

一个应用的许多支持环境都需要进行一系列的安装和运行动作的，例如使用`apt-get`安装基础环境包，或者使用`npm`、`pip`安装应用的依赖库。运行这些指令就需要使用`RUN`指令，`RUN`指令要以下两种使用形式。

- `RUN <execute_command>`
- `RUN ["executable", "param1", "param2"]`

在日常构建镜像的时候，常用的是第一种形式，但是在要执行的命令行中如果存在空格等特殊字符，就需要用第二种形式。例如`RUN ["apt-get", "update"]`与`RUN apt-get update`是一致的，但是明显`RUN`指令的第一种形式会更加简单。

每一个`RUN`指令都会将当前命令行的运行结果提交为一个新的层，这也就是说，如果在Dockerfile中连续使用了多次`RUN`指令，那么就会形成多个层，这对于镜像大小控制和文件检索是不利的。在这种情况下，可以利用Linux命令行的`&&`符号在同一行中执行多个命令，这样可以把多个命令的执行结果构建为镜像的一个层。但是如果把多个命令拼在一行书写又会造成阅读的困难，所以在Dockerfile的`RUN`指令中，经常会使用`\`来进行换行拆分，这种习惯也是符合Linux日常使用习惯的。例如以下两个优化前和优化后的示例。

```dockerfile
RUN apt-get update
RUN apt-get install -y curl ffmpeg
RUN apt-get clean -y
RUN apt-get autoclean -y
RUN apt-get autoremove -y
RUN rm -rf /var/lib/apt/lists/*
```

```dockerfile
RUN apt-get update && \
    apt-get install -y \
    curl \
    ffmpeg && \
    apt-get clean -y && \
    apt-get autoclean -y && \
    apt-get autoremove -y && \
    rm -rf /var/lib/apt/lists/*
```
这个实例中的`RUN`指令的最后一条命令`rm -rf /var/lib/apt/lists/*`是用来移除apt的缓存的，推荐使用Debian和Ubuntu的基础镜像时使用，可降低镜像大小。

### 设定工作目录

容器中也是同样有工作目录的概念的，在没有设定的情况下，所有指令的工作目录都是容器的根目录，所以为了简化目录的书写和明确命令作用路径，都需要提前设定工作目录，这实际上与`cd`命令的功能是一致的。在容器中设定工作目录使用`WORKDIR`指令，这个指令可以在Dockerfile中多次使用，如果提供的路径是相对路径，则会相对于之前的`WORKDIR`指令来改变工作目录。`WORKDIR`指令的使用格式非常简单，仅仅是`WORKDIR /path/to`。

`WORKDIR`可以为`RUN`、`CMD`、`ENTRYPOINT`、`ADD`和`COPY`指令设置工作目录，并且如果目标目录不存在，`WORKDIR`将会主动创建这个目录。

### 设定环境变量

环境变量是容器运行参数化的主要工具，通过设定环境变量可以改变容器中所负载的应用的配置，或者还可以用来解除一些应用中的硬编码值。并且这个环境变量与平常系统中所设定的环境变量是一致的。环境变量的设置是通过指令`ENV`完成的，`ENV`指令有两种使用格式。

- `ENV <key> <value>`
- `ENV <key>=<value> <key>=<value> ...`

第一种形式的`ENV`指令，每次只能设置一个环境变量，指令中环境变量名称后第一个空格之后的所有内容都将被视为一个字符串，包括空格在内。而第二种使用等号的形式则允许一次性设置多个环境变量，环境变量的值如果需要包括空格，就需要`\`的帮助。

环境变量的值会影响其后所有命令的执行，但是它的值不是固定不变的，Docker的`-e`（即`--env`）参数可以重新设置这些环境变量的值。跟`RUN`指令一样，每一个`ENV`指令也都会创建一个新的层，所以使用`ENV`指令设置的环境变量是不能用`RUN unset`来删除的。如果需要设置一个可被`RUN unset`删除的环境变量，需要使用`RUN export`来定义和设置环境变量，并且环境变量的设置和删除需要在一行内完成，这样可以不将环境变量写入层里。同样与`RUN`指令一样的，`ENV`在一次性设置多个环境变量的时候，也可以使用`\`来进行换行。

### 添加文件

容器中所负载的应用一般都是通过文件复制的方式放入镜像的，但是向容器中添加文件并不限于应用的文件。向镜像中添加文件可以通过`ADD`和`COPY`两个指令来完成，它们的使用格式如下。

- `ADD [--chown=<user>:<group>] <src>... <dest>`
- `ADD [--chown=<user>:<group>] ["<src>",... "<dest>"]`
- `COPY [--chown=<user>:<group>] <src>... <dest>`
- `COPY [--chown=<user>:<group>] ["<src>",... "<dest>"]`

`ADD`和`COPY`的使用格式都是一致的，但它们所能执行的功能并不相同。`ADD`可以复制新文件、目录甚至是远程文件URL添加到镜像中，而`COPY`则只是复制新文件和目录。两者指令都可以指定复制多个资源，并且如果在`dest`中使用相对路径，那么`ADD`和`COPY`将受`WORKDIR`指令的影响。

`--chown`参数用来改变被复制对象的所有者，这个参数在Windows容器中是没有任何作用的。每一个`ADD`和`COPY`指令都会在镜像中建立一个新的层，所以在构建镜像的时候，这两个指令也需要尽可能的少。所以为了给`ADD`和`COPY`指令屏蔽一些不需要复制的文件，可以在Dockerfile同级目录上新建一个名为`.dockerignore`的文件，其中内容写法与`.gitignore`基本一致，这个文件可以用来使`ADD`和`COPY`跳过被列举的文件。

`ADD`和`COPY`在使用的时候必须遵守以下规则。

- 被复制的路径必须位于上下文路径中，即Dockerfile所在的路径或者`docker build`指定的路径。
- 如果被复制的是路径，那么将会复制目录的全部内容，包括文件系统元数据，但不复制目录本身，只复制其中的内容。
- 如果是其他任何类型的文件，则会将其与元数据一起复制。如果尾部以`/`结尾，则会将其视为目录。
- 如果直接列举多个资源或者使用通配符匹配多个资源，那么`dest`必须是目录。
- 如果目标目录不存在，将会被自动创建。

`ADD`还提供了许多`COPY`不支持的功能，所以在使用`ADD`的时候，还需要遵守以下规则。

- 如果被复制的源是URL，且尾部不以`/`结尾，则将其作为文件下载。
- 如果被复制的源是URL，且尾部以`/`结尾，则从URL推断文件名并下载。
- 如果被复制文件是可识别的压缩格式，则会将其解压缩为目录，但从远程URL中下载的压缩文件不会被解压缩。在解压的时候，`ADD`指令具有`tar -x`的效果。

### 暴露端口和卷

容器中所提供的服务一般都是通过TCP或者UDP端口对外提供的，但是容器作为一个类似于虚拟机的系统，它上面所暴露出来的端口是受限的。而且在容器的使用中，必须把宿主机的端口和容器的端口建立关联才能正常的建立通信连接。这就需要把容器中所要与外界建立联系的端口暴露出来。暴露端口使用的是`EXPOSE`指令，其使用格式为`EXPOSE <port>[/<protocol>]`。例如暴露80端口就可以写成`EXPOSE 80`。默认情况下`EXPOSE`会暴露TCP协议的端口，如果需要暴露一个专用的UDP端口，则需要加上协议，例如`EXPOSE 8081/udp`。

`EXPOSE`指令实际上是指示容器监听的端口，是作为一种文档用途，而不是真正的发布端口，所以在指定监听端口的时候，要尽量使用通用的传统端口。

卷（volume）是Docker提供给容器的一个存放文件的容器，卷可以是一个特殊的容器，也可以是一个文件目录。指令`VOLUME`可以将镜像中的某一个文件或者目录定义为一个挂载点，允许Docker在建立容器的时候，将卷挂载在上面。容器中的应用对于挂载点的任何文件操作，都会直接传递到被挂载的卷中。`VOLUME`指令有以下两种使用格式。

- `VOLUME path1 path2 ...`
- `VOLUME ["path1", "path2",...]`

`VOLUME`指令可以使用一个数组同时创建一组挂载点。一般建议将容器中所负载的应用的数据存储区域、配置区域、可维护部分做成挂载点，使其从容器中分离出去，以便于保证容器的可移植性。

### 设定负载服务入口

一个镜像在构建结束后，必须能够完成其中负载应用的启动动作，这个启动动作一般是由`ENTRYPOINT`指令或者`CMD`指令完成的。

这两个指令中`CMD`指令比较简单，就是为容器的执行提供默认值，这个默认值可以包括可执行文件，也可以不包括可执行文件。如果不包括可抓狂文件，那么`CMD`指令必须配合`ENTRYPOINT`指令一同使用。`CMD`指令的使用格式有以下几种形式。

- `CMD ["executable", "param1", "param2",...]`，这个格式一般被称为“exec form”。
- `CMD ["param1", "param2",...]`
- `CMD executable param1 param2`，这个格式一般被称为“shell form”。

> Shell form在运行的时候是作为`/bin/sh -c`的子命令来启动的，Exec form则是直接命令执行，并不会在一个shell中。运行在shel中的命令不会传递信号，在其中启动的可执行文件不会收到任何Unix信号，例如从`docker stop`命令收到`SIGTERM`。所以一般推荐采用exec form格式来书写`CMD`和`ENTRYPOINT`中的命令。

在一个Dockerfile文件中，只能存在最多一个`CMD`指令，如果存在多个`CMD`指令，则只有最后一个指令有效。当一个镜像提供的是固定的服务且有固定的启动命令的时候，使用`CMD`指令可以省去许多额外配置问题。

`ENTRYPOINT`指令可以提供更加灵活的启动配置。设置了`ENTRYPOINT`指令的镜像可以允许将容器作为一个可执行文件启动，例如nginx就是这样的一个镜像，执行`docker run -i -t -rm -p 80:80 nginx`可以启动一个监听80端口的nginx。`ENTRYPOINT`指令的使用格式基本上与`CMD`指令相同，不同的是`ENTRYPOINT`必须指定一个可执行文件或者命令。

与`CMD`指令一样，`ENTRYPOINT`指令在Dockerfile中最多也只能存在一个，如果有多个`ENTRYPOINT`指令存在，也将只会生效最后一个。但`ENTRYPOINT`指令可以与`CMD`指令同时使用，这时`CMD`指令应该作为`ENTRYPOINT`指令的默认参数使用，或者用来在容器中执行ad-hoc命令。

> ad-hoc命令的含义为临时命令，用于解决一些临时遇到的任务。

### 一个完整Dockerfile示例

以下是一个用于部署Django应用的Dockerfile。

```dockerfile
FROM python:3.8-slim-buster
# LABEL指令用来标记镜像的元数据
LABEL maintainer="FarDawn <fardawn@archgrid.xyz>"

USER root
ENV TZ=Asia/Shanghai
ENV HOST=0.0.0.0
ENV LOG_LEVEL=info
ENV WORKERS_PER_CORE=2

WORKDIR /app/
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
    echo $TZ > /etc/timezone && \
    pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
    mkdir ./upload_files && \
    ln -snf /app/upload_files /uploads

COPY ./requirements.txt ./
RUN python -m pip install --upgrade pip && \
    pip install -r requirements.txt

COPY ./subapp ./subapp/
COPY ./mainapp ./mainapp/
COPY ./manage.py ./
COPY ./start.sh ./start.sh
RUN chmod +x ./start.sh

VOLUME [ "/uploads" ]
EXPOSE 8080

ENTRYPOINT [ "./start.sh" ]
```

> 复制命令在有.dockerignore的支持下，还可以直接使用`COPY . .`来实现内容的复制，可以减少层。

## 镜像构建原则及技巧

Docker镜像的构建虽然可以根据需要非常随意的构建，但是沿着一些原则构建出来的镜像在编译和运行上会更加有效率和简单。所以简而言之，Docker镜像的构建应该追求实现以下目标。

- 镜像构建时间更短，时间就是金钱，我的朋友。
- 构建出的镜像大小更小，有利于传播。
- 使用更少的镜像层，可以节省构建时间，也可以使镜像更小。
- 充分利用镜像层的缓存，可以大幅度降低构建时间。Docker会缓存未发生改变的镜像层。
- 增加Dockerfile的可读性，可以加强Dockerfile的可维护性。
- 让容器使用起来更加简单，Dockerfile中包含的配置越多，容器的使用自由度就越大。


所以根据网上的大量镜像构建经验，要达到以上目标，可以借鉴和使用以下经验。

- 编写`.dockerignore`文件，简化`COPY`和`ADD`。
- 每个容器只运行一个应用，只提供一项服务。只运行单一应用非常便于横向扩展，如果需要结合其他的服务，可以使用Docker Compose或者Kubernetes进行容器编排。
- 尽量合并多个`RUN`指令。
- 指定基础镜像的具体标签，尽量不要选择使用`latest`。你的应用不一定能完美兼容新版的基础镜像，总是使用`latest`镜像可能会导致构建失败。
- 在每个`RUN`指令后，删除不再需要的中间文件和临时文件。
- 选择合适的基础镜像，例如Alpine。
- 使用`ENTRYPOINT`设定应用入口，可以提供在环境变量之外使用参数配置应用的能力。
- 使用Exec form的`ENTRYPOINT`，可以使应用能够接收到所有的Unix信号。
- 设置`WORKDIR`，可以使命令和路径更加简短。
- 优先使用`COPY`，`COPY`指令相比`ADD`指令更加简单纯粹。
- 合理调整`COPY`和`RUN`的顺序，`COPY`和`RUN`都会建立新的层，但是如果这个层未发生变化，Docker在构建镜像的时候就会使用缓存。如果一个层发生了变更，即便其后的层之前存在缓存，Docker也会对其进行重新构建。所以，在Dockerfile中，低频的变化可以靠前放置，高频变化向后放置。
- 合理设置环境变量、端口和卷。
- 使用`LABEL`设置镜像元数据。
- 添加`HEALTHCHECK`，方便在运行中对容器进行监测。

### 基础镜像的选择

除Windows基础镜像没有什么可选的余地以外，使用Linux作为基础镜像会面临非常多的选择。而在基本的系统镜像之上，还有语言级镜像、应用级镜像等，但总之，选择越高级的基础镜像，在编写Dockerfile时需要做的底层工作越少。一般的语言级基础镜像和应用级基础镜像都会标明他们构建时所依赖的系统基础镜像。

不同的系统基础镜像拥有不同的特性，以下是一些常用的基础Linux镜像及其特征。

- **busybox**，不到2MB大小，主要用于做测试使用，是一个非常简化的嵌入式Linux系统。
- **alpine**，约4MB大小，主要用于测试和生产环境使用。alpine基于busybox，精简了大部分的组件，其中也包括bash等终端，如果在其中执行编译是比较不合适的，需要安装大量的组件，并且alpine采用musl libc，在进行涉及C/C++的编译时，也需要安装更多的组件。alpine采用apk作为包管理器。alpine非常适合作为部署用镜像，尤其是支持不需要编译任何内容的应用。
- **centos**，约200MB大小，RedHat的社区版镜像，主要用于生产环境，运行非常稳定。centos的缺点在于如果使用其中的包管理器安装组件，其组件的版本会非常老旧。
- **ubuntu**，约80MB大小，基于Debian的Linux发行版，主要用于生产环境。ubuntu相比debian拥有更快速的软件包更新，也支持更多的自定义内容，但是ubuntu的运行效率相对于debian要低一些。如果要构建人工智能的容器环境，ubuntu是唯一的选择，因为只有ubuntu有Nvidia CUDA的支持。
- **debian**，约100MB大小，主要用于生产环境。debian目前还提供了一个slim版的镜像，其大小大大缩减。debian镜像的性能是目前这几个镜像中最好的，对于软件包的支持度和更新速度也是比较优秀的。debian在发布镜像的时候，通常都使用其版本代号来表示debian的版本，例如buster为debian 10，stretch为debian 9，jessie为debian 8。版本代号后面如果标记了slim，则是debian的缩减版，其性能相较完整版会进一步提高。

所以在一般的部署环境中，如果镜像需要编排应用的运行环境，推荐使用debian作为基础系统镜像；如果要进行神经网络计算，并且需要显卡支持，那就只能选择ubuntu；如果只需要运行例如nginx之类已经准备好的服务，就可以选择alpine来最大化减小镜像体积；如果只是需要对应用进行测试，可以选用busybox。

## 多级构建

多级构建允许大幅减小最终镜像的大小，而不用费力减少中间层的数量和文件数量。不同阶段的构建只为了单一的目标存在，而不必关心其他构建过程。多级构建非常适合于那些最终部署的文件与编译环境无关，也就是编译环境和部署环境可以完全不同的应用场景。多级构建不是所有的Docker版本都支持的，需要使用17.05版以后的Docker。

传统进行镜像构建的时候，开发者常常需要在自己的机器上做编译，然后将编译出的程序打包入部署镜像。这就要求编译环境与部署环境必须是兼容匹配的，例如在ubuntu上进行的编译，也需要在部署时也部署在ubuntu基础镜像上，但是如果部署在alpine基础镜像上，可能就会导致失败。

如果按照一般的思路在一个镜像中既对应用进行打包，又进行部署，那么这个镜像里就会携带许多不必要的内容，例如应用的源文件，编译的中间过程文件等。多级构建就是在一个Dockerfile中使用多次`FROM`指令，不同的`FORM`指令区分不同的构建过程，也会形成不同的中间构建镜像，最终的镜像构建步骤可以从中间镜像中提取一些内容而不是全部内容来组装最终的镜像，这样最终构建的镜像大小就大大降低了。

以下通过一个构建Angular应用并将其通过Nginx进行部署的示例来说明。

### 构建Angular站点的示例

```dockerfile
FROM node:14-buster-slim AS builder

WORKDIR /usr/src/app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build --prod

FROM nginx:1-alpine
LABEL maintainer="FarDawn <fardawn@archgrid.xyz>"

COPY --from=builder /usr/src/app/dist/angular-app/ /usr/share/nginx/html
COPY nginx.conf /etc/nginx/
```

Dockerfile中首先使用了基于debian 10 slim的NodeJS 14基础镜像来进行Angular应用的编译，编译结束后，又使用基于alpine的Nginx基础镜像来负载Angular应用。在这个实例中，最关键的两个位置，一是NodeJS基础镜像使用`AS`进行了命名，这个命名可以提供后续的构建阶段对于本阶段中内容的访问；二是在`COPY`指令中使用了`--from`参数，这个参数可以声明要复制的文件来源阶段。

通过对构建阶段的命名和文件复制来源的指定，各个构建阶段就被串联了起来。其实我们在每个构建阶段中所需要的，也仅仅是前一个构建阶段的结果。

多级构建会在系统中产生体积较大的中间镜像，这种镜像目前不能自动清除，如果需要回收系统空间，可以使用命令`docker system prune`来清理。
