Docker 镜像的制作和使用

镜像 Layer(层)

镜像里的内容是按「层」来组织的,「层」可以复用,一个完整的镜像也可以看做是一个「层」。多个「层」叠加在一起就形成了一个新的镜像,这个镜像也可以作为别的镜像的基础「层」进行更加复杂的镜像构建。下图展示了一个镜像的内部结构。

这个目标镜像使用 Debian 镜像作为基础镜像开始构建,也就是说 Debian 镜像是目标镜像的第一「层」;往上的两层分别使用了 ADD 指令将 emacsapache 添加到了目标镜像中,每一个 ADD 指令都将产生新的一个「层」,最后这个目标镜像就是一个拥有三「层」的镜像。每新增一「层」时,将要生成的这一「层」镜像都会默认使用上一步构建出的「层」作为自己的基础镜像,上图中的箭头表示的就是这种引用关系。

所以,「层」和「镜像」是等价的,当这一「层」之上没有其他「层」时,我们就可以将这一「层」及其下面的所有「层」合起来称作一个「镜像」。如果这一「层」只是在构建镜像过程中生成的一个「中间层」,即这一「层」不会被用来启动容器,那么就可以称作「层」。总的来说,能用来启动容器的就称作「镜像」,其他都称作「层」。

制作镜像

制作镜像的过程和在操作系统上安装软件的过程几乎是完全一样的,唯一的区别是制作镜像需要使用 Dockerfile 文件来编写要执行的操作。请注意,Dockerfile 里的所有指令,除了 CMDENTRYPOINT,都是给 Docker 引擎执行的,目的是制作出目标镜像,这些指令不是启动容器的时候执行的。

下面的例子将一步步演示从 0 开始制作一个在 CentOS7.2 操作系统上安装了 openjdknginx 并运行一个 Java 应用程序的镜像,这个过程同时也将体现镜像分层复用的思想。

镜像分层复用思想

Dockerfile 中的每个指令都会生成一个「层」,最终的目标镜像就是由多个「层」组成的。如果制作 B 镜像的 Dockerfile 中存在某个指令与制作 A 镜像的 Dockerfile 中的某个指令完全一致,那么制作 B 镜像时就会复用制作 A 镜像时生成的「中间层」,而不会再去创建一个新的「层」,这就是「镜像分层复用」思想。

第一步,制作一个支持中文的 CentOS 镜像

官方为我们提供了 Linux 各种发行版的镜像,我们日常的所有镜像构建都是基于这些镜像来完成的。由于官方的 CentOS 镜像并不支持中文字符集,所以我们需要先制作一个支持中文的镜像出来

编写 Dockerfile

1
2
3
4
5
6
7
FROM centos:7.2.1511
RUN yum install -y telnet kde-l10n-Chinese net-tools vim inetutils-ping unzip \
&& ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& localedef -c -f UTF-8 -i zh_CN zh_CN.utf8 \
&& yum clean all
ENV LC_ALL "zh_CN.UTF-8"
CMD ["/bin/bash"]

FROM 指令表示我们从官方提供的 CentOS 镜像开始构建我们自己的镜像。centos 是镜像的名称,7.2.1511 是镜像的版本。

RUN 指令表示在构建镜像时我们要执行的 shell 命令。之前的 FROM 指令相当于给了我们一个干净的操作系统,我们在这个系统上要执行的各种操作,如安装软件、创建目录等就都要书写在这个 RUN 指令之后。理论上你可以对每一个要执行的 shell 命令都使用一个 RUN 指令,比如我们将上面的 RUN 指令改写为下面的样子:

1
2
3
4
RUN yum install -y telnet kde-l10n-Chinese net-tools vim inetutils-ping unzip
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN localedef -c -f UTF-8 -i zh_CN zh_CN.utf8
RUN yum clean all

这样编写出来的 Dockerfile 文件是没有任何问题的,镜像最终也能够制作成功,但是这并不切合镜像分层复用的思想,因为我们几乎不会用到上面单个 RUN 指令生成的「中间层」。这样编写指令只会增加磁盘空间的占用,也让 Dockerfile 变得非常臃肿。

需要特别注意的是,如果 RUN 指令中有安装软件的操作,那就一定要在 RUN 指令的最后清除掉软件仓库的缓存,这样可以有效的瘦身镜像。

ENV 指令表示在构建镜像时要在操作系统中设置的环境变量。这个指令每次只能设置一个环境变量,如果需要设置多个环境变量,则需要编写多个 ENV 指令。

CMD 指令表示的是容器启动时要执行的操作,通常会设置为应用程序的启动脚本,这个指令一定是出现在 Dockerfile 的最后。被指定的操作一定是能够挂起一个进程的操作,否则容器启动并执行完这个操作后就会退出。

构建镜像

构建镜像时需要告诉 Docker 引擎 Dockerfile 的位置、镜像的名称和构建位置三个信息,下面是一个简单的镜像构建命令:

1
docker build -t myorg/centos:7.2 .

由于我们没有使用 -f 参数指定 Dockerfile 文件的位置,Docker 引擎将默认使用当前目录下的 Dockerfile 文件进行构建。镜像的名称为 myorg/centos:7.2,其中 myorg 是组织名,但不是必须的。如果你需要将镜像发布到公网去,或者尽可能的避免和别人制作的镜像发生冲突,通常还是建议加上组织名。最后的 . 表示构建位置在当前目录。通常建议将 Dockerfile 和构建所需要的文件放在一个目录下,然后在这个目录下执行构建。由于在构建开始前 Docker 引擎会读取构建目录下的所有文件,为了提高构建速度,请不要将构建中不需要的文件放到构建目录下。下面是执行上述构建命令后的输出,其中 shell 命令的输出内容被裁减掉了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Sending build context to Docker daemon  2.048kB
Step 1/4 : FROM centos:7.2.1511
---> 4cbf48630b46
Step 2/4 : RUN yum install -y telnet kde-l10n-Chinese net-tools vim inetutils-ping unzip && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && localedef -c -f UTF-8 -i zh_CN zh_CN.utf8 && yum clean all
---> Running in 724ac4950fc9
// shell 命令执行的输出
---> 2703f1dd2526
Removing intermediate container 724ac4950fc9
Step 3/4 : ENV LC_ALL "zh_CN.UTF-8"
---> Running in 2f49ec282e95
---> f6919bceb45f
Removing intermediate container 2f49ec282e95
Step 4/4 : CMD /bin/bash
---> Running in aea69f51eefd
---> e8e1d37c61a1
Removing intermediate container aea69f51eefd
Successfully built e8e1d37c61a1
Successfully tagged myorg/centos:7.2

Sending build context to Docker daemon 2.048kB 表示在构建开始前,Docker 引擎读取到了构建目录下共有 2.048k 的文件。这里也印证了前文提到的不要将构建无关的文件放到构建目录下,否则会影响构建速度的结论。

Step 1/4 : FROM centos:7.2.1511 表示构建镜像的第一步是使用 centos:7.2.1511 镜像作为基础镜像,由于没有任何变更操作,所以下面输出的 4cbf48630b46 就是原本这个 CentOS 镜像的 ID。如果在构建时本地没有 centos:7.2.1511 这个镜像,那么这里还将输出 Docker 引擎从镜像仓库拉取这个镜像的信息。

Step 2/4 ... 表示构建镜像的第二步是执行这些 shell 命令。其下的 Running in 724ac4950fc9 表示 Docker 引擎启动了一个 ID 为 724ac4950fc9 的容器并在容器内部执行这些操作。接着 ---> 2703f1dd2526 表示这些 shell 命令执行完成后生成了 ID 为 2703f1dd2526 的中间「层」。最后的 Removing intermediate container 724ac4950fc9 表示当中间「层」生成完成后,删除了刚才使用的容器。

Step 3/4 ...Step 4/4 … 表示的意义和 Step 2/4 类似,这里不再赘述。

Successfully built e8e1d37c61a1 表示最终构建出来的镜像 ID 是 e8e1d37c61a1

Successfully tagged myorg/centos:7.2 表示把镜像的名称设置为了构建命令中指定的 myorg/centos:7.2

查看镜像

构建完成的镜像会直接被 Docker 管理,而不会给我们生成一个文件。使用 docker images 命令可以查看到当前已有的镜像,如下所示:

1
2
3
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
myorg/centos 7.2 e8e1d37c61a1 14 minutes ago 272MB
centos 7.2.1511 4cbf48630b46 3 months ago 195MB

可以看到第一个镜像就是刚才创建的镜像,大小是 272MB,比原本官方的镜像多了 77MB。在制作这个镜像的过程中还生成了 2 个中间「层」,我们可以使用 docker images -a 命令看到它们。

1
2
3
4
5
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
myorg/centos 7.2 e8e1d37c61a1 18 minutes ago 272MB
<none> <none> f6919bceb45f 18 minutes ago 272MB
<none> <none> 2703f1dd2526 18 minutes ago 272MB
centos 7.2.1511 4cbf48630b46 3 months ago 195MB

由于中间「层」没有名字,所以名称和 TAG 都显示为 <none>。你可以尝试使用 docker rmi f6919bceb45f 命令来删除一个中间「层」,你会得到一个如下的错误提示:

1
Error response from daemon: conflict: unable to delete f6919bceb45f (cannot be forced) - image has dependent child images

从上面构建镜像的输出可以看出,f6919bceb45f 这一「层」,即 Step 3/4 这一步生成的「层」被 e8e1d37c61a1 所引用,所以这里不能够直接删除这个中间「层」。回想一下前文的那张镜像层次图中的引用箭头,这就是「层」与「层」直接的引用关系。

使用镜像

使用镜像就是利用制作好的镜像来启动容器,如下面的命令:

1
docker run --name mycontainer myorg/centos:7.2

docker run 是启动容器的命令,--name 用于指定容器的名称,最后面是启动容器所使用的镜像名称。命令执行完成后使用 docker ps 查看运行中的容器,这时你会发现并没有任何容器出现;再使用 docker ps -a 查看所有容器将会有如下信息:

1
2
CONTAINER ID        IMAGE               COMMAND             CREATED                  STATUS                    PORTS               NAMES
b4cae07cb40c myorg/centos:7.2 "/bin/bash" Less than a second ago Exited (0) 1 second ago mycontainer

可以看到刚才启动的 mycontainer 容器的状态(STATUS)为 Exited,这表示容器已经退出了,即没有在运行状态,那为什么容器启动后就会退出呢?前文已经提到过,Dockerfile 中 CMD 指令和 ENTRYPOINT 指令指定的是容器启动后的操作,这个操作必须要能够挂起一个进程,否则容器启动完成后就会退出。查看刚才编写的 Dockerfile 可以看到,CMD 指令指定的命令是 /bin/bash,这个命令并不会挂起进程。

这个问题可以通过增加 docker run 命令的参数来解决,如下面的命令:

1
docker run -d -i --name mycontainer2 myorg/centos:7.2

命令执行完成后再次执行 docker ps 查看运行中的容器就能看到这个名为 mycontainer2 的容器了。其中 -d 参数表示让容器在后台运行,-i 参数表示保持标准输入打开,这样容器就不会在启动完成后立即退出了。

第二步,制作一个带有 openjdk 和 nginx 的镜像

这一次的镜像构建使用我们第一步构建出的镜像作为基础镜像。

编写 Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
FROM myorg/centos:7.2
RUN echo "[nginx]" >> /etc/yum.repos.d/nginx.repo \
&& echo "name=nginx repo" >> /etc/yum.repos.d/nginx.repo \
&& echo "baseurl=http://nginx.org/packages/centos/7/\$basearch/" >> /etc/yum.repos.d/nginx.repo \
&& echo "gpgcheck=0" >> /etc/yum.repos.d/nginx.repo \
&& echo "enabled=1" >> /etc/yum.repos.d/nginx.repo \
&& yum makecache \
&& rpm --rebuilddb \
&& yum install -y java-1.8.0-openjdk-devel.x86_64 nginx \
&& yum clean all
ENV JAVA_HOME /usr
CMD ["/bin/bash"]

这一份 Dockerfile 中的指令在上文中已经解释过了,这里不再赘述。注意 RUN 指令后 shell 命令多行排版的方式是以 \ 结尾,以 && 开头。

构建镜像

这一次构建的镜像命名为 myorg/base:centos7.2.x64-ngx-java8。在为镜像命名时,应当在名称和版本两个部分充分描述这个镜像,这样便于快速了解镜像的功能,构建命令如下:

1
docker build -t myorg/base:centos7.2.x64-ngx-java8 .

第三步,制作可部署的应用系统镜像

经过第二步的构建,我们已经拥有了一个带有 Java 运行环境和 Nginx 的镜像,这一步就是要将应用系统也放入镜像中,并通过指令让容器启动后就去执行应用系统启动操作。

编写 Dockerfile

1
2
3
4
5
6
7
8
9
10
FROM myorg/base:centos7.2.x64-ngx-java8
COPY login-deploy-1.0 /home/admin/login/
COPY login-ui /home/admin/login-ui/
COPY nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /home/admin/entrypoint.sh

RUN chmod +x /home/admin/entrypoint.sh
EXPOSE 80
VOLUME ["/home/admin/logs"]
ENTRYPOINT ["sh", "/home/admin/entrypoint.sh"]

COPY 指令用于将文件或目录拷贝到镜像中指定的位置。如果拷贝的是一个目录,指定镜像中的位置时通常建议在最后加上 /,以避免将目录拷贝成文件的情况。与 COPY 相似的指令是 ADD,后者可以将一个压缩文件拷贝到镜像中并自动解压。由于 ADD 的自动解压功能可能导致解压出来的文件的名称不可控,所以通常是推荐使用 COPY 命令来完成拷贝工作,压缩文件在拷贝前手动解压即可。

EXPOSE 指令用于指定使用这个镜像启动的容器可以通过哪个端口和外界进行通信。换言之,只有 EXPOSE 指令指定的端口才能够和宿主机上的端口做映射。比如这里 EXPOSE 了 80 端口,那么在启动容器的时候就可以将宿主机的 8888 端口映射到容器的 80 端口,这样外界访问宿主机的 8888 端口就相当于访问容器内部的 80 端口。

VOLUME 指定用于指定容器数据的挂载点。容器在运行时会产生各种数据,由于容器和宿主机天然是隔离的,所以在宿主机上并不能看到容器内的数据,当容器被销毁时,这些数据也会随之销毁,无法找回。为了将容器内产生的数据存放到宿主机上,我们可以在制作镜像时指定某些目录为挂载点,然后将容器运行时产生的数据指定输出到这些目录中。当容器启动时,Docker 就会自动在宿主机上创建数据卷来映射挂载点,这样容器中产生的数据就会保存在宿主机上的这个数据卷内。数据卷有自己独立的生命周期,即使删掉了容器,数据卷也还会存在。

Docker 会使用随机 ID 给数据卷命名,这非常不便于管理。在启动 Docker 容器时可以使用 -v 参数来指定数据卷的名称,如 -v myappdata:/home/admin/logs。这样当我们启动容器时,Docker 就会在宿主机上创建名为 myappdata 的数据卷。查看数据卷使用命令 docker volume ls

ENTRYPOINT 指令的作用和前文介绍的 CMD 指令的作用是基本一致的。区别在于前者指定的命令不会被覆盖,而后者指定的命令会被启动容器时附带的命令所覆盖。对于应用程序镜像来说,通常建议使用 ENTRYPOINT 指令。在这份 Dockerfile 中,ENTRYPOINT 指令表示在容器启动后执行 /home/admin/entrypoint.sh 这份脚本。

构建镜像

这一次构建的镜像命名为 myorg/login:20190108

1
docker build -t myorg/login:20190108 .

使用镜像

经过上面的三个步骤,一个可以使用的应用系统镜像就制作完成了,使用下面的命令来启动容器:

1
docker run -d --name loginService -p 8800:80 -p 9090:8080 -v myappdata:/home/admin/logs myorg/login:20190108

-d 参数表示让容器在后台运行。

--name 参数指定容器的名称。

-p 参数指定端口映射关系。命令中的关系为将宿主机 8800 端口映射到容器中的 80 端口,将宿主机 9090 端口映射到容器中的 8080 端口。

-v 参数指定挂载点对应数据卷的名称。需要特别说明的是,挂载点也可以指定一个宿主机目录去挂载,这样 Docker 将不会创建数据卷。比如使用宿主机的 /data/appdata 目录去挂载,参数值修改为 -v /data/appdata:/home/admin/logs。挂载前宿主机目录必须存在,Docker 不会自动创建,并且要保证具有读写权限。

修改镜像

对于常用如 MySQL、Kafka、Redis 等中间件,官方已经为我们提供了经过测试的镜像,我们可以直接拿来使用。但是由于业务的具体需求等原因,我们通常需要对这些镜像进行修改。所谓修改镜像,其实就是基于这些官方镜像制作出新的镜像。在下面的这个例子中,我们将一步步把官方的 mysql:5.7 镜像进行修改。

第一步,修改时区

很多官方镜像都使用的零时区,这显然不符合国情,所以 通常我们都需要把官方镜像的时区调整为东八区。调整时区需要安装 tzdata 这个软件,所以我们需要事先确定官方镜像是基于哪种 Linux 发行版进行构建的,否则我们将不知道该使用什么软件安装命令。你可以登录 Docker Hub 搜索对应的镜像,然后查看官方放置在 GitHub 上的 Dockerfile 文件来确定相关信息。

编写 Dockerfile

1
2
3
4
5
6
7
FROM mysql:5.7
ENV TZ=Asia/Shanghai
COPY customer.cnf /etc/mysql/conf.d/
RUN apt-get update \
&& apt-get install -y tzdata \
&& ln -s -f /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& rm -rf /var/lib/apt/lists/*

修改时区的同时我们通过增加自定义 MySQL 配置文件来调整 MySQL 字符集、时区、连接数等配置,内容如下:

1
2
3
4
5
6
7
8
9
10
[client]
default-character-set=utf8mb4
[mysql]
default-character-set=utf8mb4
[mysqld]
character-set-client-handshake=FALSE
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
max_connections=1000
default-time_zone='+8:00'

需要注意的是我们并没有使用 CMD 指令或 ENTRYPOINT 指令来指点容器启动后要执行的操作,因为在很多情况下,除非我们查阅官方镜像的 Dockerfile 文件,否则我们无法获知原本的启动操作是什么。所以只要我们变更的操作不影响启动流程,那么就可以不指定启动操作,让镜像默认使用基础镜像的启动操作。

构建镜像

这一步我们将镜像命名为 myorg/mysql:5.7_bjtime_utf8mb4,执行构建命令:

1
docker build -t myorg/mysql:5.7_bjtime_utf8mb4 .

第二步,自动建库

在系统部署情况下,我们系统 MySQL 容器启动后就能将所需要的数据库建立好,这样可以避免我们再手动去建库。

编写 Dockerfile

1
2
3
4
5
6
7
8
9
FROM myorg/mysql:5.7_bjtime_utf8mb4

COPY db_login.sql /sqls/db_login.sql
COPY privileges.sql /sqls/privileges.sql
COPY entrypoint.sh /entrypoint.sh

RUN chmod +x /entrypoint.sh
ENV MYSQL_ALLOW_EMPTY_PASSWORD yes
ENTRYPOINT ["sh", "/entrypoint.sh"]

本次构建基于上一步的镜像继续构建,db_login.sql 是建库建表的 SQL 脚本,privileges.sql 是添加数据库用户信息的脚本, entrypoint.sh 是容器启动后要执行的脚本。

privileges.sql 中主要是修改了 ROOT 用户的密码并允许远程登录,内容如下:

1
2
3
4
update mysql.user set authentication_string=password("123456") where user = "root";
create user 'root'@'%' identified by '123456';
grant all privileges on *.* to 'root'@'%';
flush privileges;

entrypoint.sh 脚本负责启动 MySQL Server 并执行 db_login.sqlprivileges.sql,内容如下:

1
2
3
4
5
6
#! /bin/sh
service mysql start
sleep 3
mysql < /sqls/db_login.sql
mysql < /sqls/privileges.sql
tail -f /dev/null

最后的 tail -f /dev/null 是为了让进程挂起,禁止容器退出。

ENV MYSQL_ALLOW_EMPTY_PASSWORD yes 表示允许容器启动时 MySQL ROOT 用户没有密码。官方默认要求启动时必须设置 ROOT 用户密码,否则容器无法启动。

构建镜像

至此,一个启动即建库并支持 utf8mb4 和东八区的 MySQL 镜像就修改完成了,这个镜像中关于 MySQL 安装和配置部分完全是复用的官方镜像,我们只做了定制化的修改。最后,我们将镜像命名为 myorg/mysql:5.7_login_20190108

1
docker build -t myorg/mysql:5.7_login_20190108 .

使用镜像

1
docker run -d --name login_db -p 3306:3306 -v /data/mysqldata:/var/lib/mysql myorg/mysql:5.7_login_20190108

官方镜像

当你需要制作一个镜像,尤其是中间件镜像时,最好的选择是先去 Docker Hub 搜索是否已有相关的官方镜像。基于官方镜像或者别人发布的镜像来进行定制化比自己从头做一个镜像更方便更可靠。

Docker Hub 是世界上最大的 Docker 镜像仓库,Docker 官方和世界各地的开发者都在这上面发布自己制作的镜像。在这里你可以找到各种镜像的使用说明,也能找到其 Dockerfile 来学习。比如我们上面使用官方的 MySQL 镜像来定制化,那么 ROOT 密码该怎么设置,数据挂载点在哪里,开放了哪些端口这些问题,你都能在镜像文档中找到答案。

最后

Docker 镜像的制作技术非常简单,难点在于你是否能够事先规划好镜像内容。当你编写 Dockerfile 时,你的脑海里应该具有镜像制作完成之后的一个全貌,这样你编写的 Dockerfile 才是可靠有效的。编写 Dockerfile 其实就像是给你一个干净的操作系统,让你去安装软件,设置目录,启动应用类似,明确了目的,流程就会很清晰。