前言
此篇博文是笔者所总结的 Docker 系列之一;
本文为作者的原创作品,转载需注明出处;
Docker 镜像概述
在传统环境中,软件在运行之前也需要经过代码开发 -> 运行环境准备 -> 安装软件 -> 运行软件
等环节,开发人员往往要负责准备测试环境,系统架构师往往需要准备生产环境,安装和配置繁杂的软件,以至于在大规模应用部署过程中,开发人员和系统架构师的过多精力都消耗在了测试和生产环境的部署上;而通过 Docker 镜像,将中间两个步骤,准备运行环境,安装软件的过程给封装成了镜像。这样,开发人员就可以专注于开发工作,系统架构师就可以专注于系统扩展,高可用,安全性等等的本职工作上了。
生成 Docker 镜像
Docker 的镜像生成常用的有两种方式
- 创建一个容器,运行若干命令,再使用 docker commit 来生成一个新的镜像。不建议使用这种方案。
- 创建一个 Dockerfile 然后再使用 docker build 来创建一个镜像。大多人会使用 Dockerfile 来创建镜像。
Docker commit 生成镜像
Docker build 使用 Dockerfile 生成镜像
准备一份 Dockerfile
创建一份 Dockerfile Dockerfile
1 | root@ubuntu:~# touch Dockerfile |
准备如下内容,
1 | FROM ubuntu:14.04 |
这是一个非常简单的Dockerfile
,试验的目的是基于 Ubuntu 14.04 基础镜像
安装 ntp 基础软件,从而生成一个新的镜像
。
通过 docker build 进行构建的过程
特别注意,运行 docker build 命令的时候最后要接一个.
符号;docker build -t dockerfile .
-t 表示给镜像一个 REPOSITORY 和 TAG 名称,如果只输入 REPOSITORY,那么 TAG 默认为 lastest。-f 表示指定构建文件,如果不使用 -f,那么默认在当前目录使用名为Dockfile
的构建文件。更多 docker build 的解释参考 https://docs.docker.com/engine/reference/builder/
1 | root@ubuntu:~# ls |
从日志中可以看到,每一行命令都被当做一个Step
执行,下面我们根据每个步骤来逐一分析
Step 1: FROM ubuntu:14.04
获取基础镜像 ubuntu:14.04. Docker 首先会在本地查找,如果找到了,则直接利用;否则从 Docker registry 中下载
1 | Step 1 : FROM ubuntu:14.04 |
这里下载了一个新的镜像,ubuntu:14.04,image id 3f755ca42730
,使用命令docker images
可以看到 docker 加载了该镜像。
1 | mac@ubuntu:~$ docker images |
可见,该镜像由 5 层只读镜像所构成。
1 | mac@ubuntu:~$ docker inspect --format={{'.RootFS.Layers'}} 3f755ca42730 |
3f755ca42730
将作为基础镜像构建自定义的镜像。
Step 2: MAINTAINER mac “mac@mac.com“
此步是设置作者的相关信息
1 | Step 2 : MAINTAINER mac "mac@mac.com" |
Docker 会创建一个临时容器f0765b7bdf80
,然后运行 MAINTAINER 命令,再使用 docker commit 生成新的镜像0d4795488869
,最后删除临时容器f0765b7bdf80
,然后运行。
可惜我没能打印出临时容器 f0765b7bdf80 的日志,下面是别人打印出来的另外一个相同步骤的临时容器 1be8f33c1846 的相关日志
1 | 2016-09-16T21:58:09.010886393+08:00 container create 1be8f33c18469f089d1eee8c444dad1ff0c7309be82767092082311379245358 (image=sha256:4a725d3b3b1cc18c8cbd05358ffbbfedfe1eb947f58061e5858f08e2899731ee, name=focused_poitras) |
从日志中,我们可以清晰的看到,该临时容器的create
,commit
和destory
的整个生命周期的过程。而,正是通过该临时的容器f0765b7bdf80
,Docker 通过命令docker commit
生成了一个新的镜像0d4795488869
,而该镜像,只是一个中间过度的镜像
, 用于保存镜像的中间状态;需要注意的是,该中间镜像0d4795488869
是不能通过命令docker images
查找得到的,必须使用docker images -a
1 | mac@ubuntu:~$ docker images -a |
可见,Docker 使用了一个 REPOSITORY 为 0d4795488869
来保存中间的状态,有趣的是,你可以观察到它的大小与 ubuntu 基础镜像3f755ca42730
一样,没有发生任何的变化,也就是说,该步骤并没有新增任何的文件,所以大小一致。我们可以通过docker inspect
来验证
1 | docker inspect --format={{'.RootFS.Layers'}} 0d4795488869 |
可见,它的镜像的层次与 ubuntu 基础镜像3f755ca42730
一致,表示,没有新增任何的文件。那问题是,这个步骤,在什么地方发生了改变呢?
好的,我们来对比Step 1
和Step 2
所产生的镜像的异同,我们发现只是在配置文件上发生了细微的变化。
1 | mac@ubuntu:~$ docker inspect --format={{'.ContainerConfig.Cmd'}} 3f755ca42730 |
1 | mac@ubuntu:~$ docker inspect --format={{'.ContainerConfig.Cmd'}} 0d4795488869 |
我们发现,Step 2
在 Docker 的全局配置文件中添加了作者的身份信息。
Step 3: RUN apt-get update
1 | Step 3 : RUN apt-get update |
可见,该步骤与Step 2
类似,通过临时容器0e86dcebf6ba
生成了一个中间镜像413240c5a24e
,那么该中间镜像生成了什么呢?我们发现,它新增了一层镜像曾(既添加了一层 AUFS 文件层)
1 | mac@ubuntu:~$ docker inspect --format={{'.RootFS.Layers'}} 413240c5a24e |
对比之前的Layers
,我们发现,总共六层镜像,新生成的一层镜像为sha256:9f68e6599b8a35ec4a77e6e93232cced6c2bfee34477e48ca64e7bd1cd8303c8
Step 4: RUN apt-get -y install ntp
1 | Step 4 : RUN apt-get -y install ntp |
与之前类似,通过中间容器70c3eac319ae
,生成了一个中间镜像a22a846114ed
,
1 | mac@ubuntu:~$ docker inspect --format={{'.RootFS.Layers'}} a22a846114ed |
新生成的一层镜像为sha256:3ebffa962b3d30e5691762a4445559e5f1608ede1c22a60fb51a98587d74990a
Step 5: EXPOSE 5555
1 | Step 5 : EXPOSE 5555 |
通过临时容器d749f2d262e8
生成了一个中间镜像af92f237e0ce
, 该镜像在元数据上做了些改动,在元数据上做了两处改动如下,
ContainerConfig.Cmd
1
2
3
4
5
6
7
8"ContainerConfig": {
...
"Cmd": [
"/bin/sh",
"-c",
"#(nop) ",
"EXPOSE 5555/tcp"
],Config.ExposedPorts
1
2
3
4
5"Config": {
......
"ExposedPorts": {
"5555/tcp": {}
},
Step 6 : CMD /usr/sbin/ntpd
1 | Step 6 : CMD /usr/sbin/ntpd |
通过中间容器e351a8284085
,哈哈,生成了我千呼万唤始出来的最终镜像a1ed5e6093dc
,同样,该镜像实际上只是改变了相关元数据
1 | "Cmd": [ |
1 | mac@ubuntu:~$ docker images | grep a1ed5e6093dc |
镜像的名称dockerfile:latest
。
总结
1 | mac@ubuntu:~$ docker history a1ed5e6093dc |
- 容器镜像包括元数据和文件系统,其中文件系统是指对基础镜像的文件系统的修改,元数据不影响文件系统,只是会影响容器的配置
- 每个步骤都会生成一个新的镜像,新的镜像与上一次的镜像相比,要么元数据有了变化,要么文件系统有了变化而多加了一层
- Docker 在需要执行指令时通过创建
临时容器
,再通过 docker commit 来生成中间镜像
。 - Docker 会将中间镜像都保存在缓存中,这样将来如果能直接使用的话就不需要再从头创建了。关于镜像缓存,请搜索相关文档。
Docker 容器
docker run
docker run <image>
会依赖指定的image
启动一个新的容器,启动过程,
- 通过 namespaces 进行进程、网络、用户级别启动一个 PID=1 的进程,通过其可以 fork 出任意多的子进程,从而生成一个基于操作系统之上的虚拟机原型。
- 启用 cgroups 规则,限制该虚拟机的 CPU、内存、带宽使用率等
- 使用 AUFS 建立一层可写层,保存容器产生的新的数据等。
--rm
: 我们通常使用该参数来运行一个容器,一旦容器退出,将会自动删除该容器,同事删除其产生的中间数据。
Docker 容器层

如图,非常形象的描述了容器层其实就是基于相同的基础镜像层之上的一层可写的 AUFS 文件层而已。
COW(Copy On Write) 写时复制
每一次docker run
都会新生成一层中间镜像
,既生成一层只读的 AUFS 文件层,所以,为了减少中间镜像
层的层数,应该尽量将多个命令放置在同一个docker run
中,比如,我们修改上述的 Dockerfile 文件
1 | FROM ubuntu:14.04 |
我们将RUN apt-get update
和RUN apt-get -y install ntp
合并成一个docker run
命令,那么,这样,我们就可以有效的减少一层镜像中间层
。
使用容器需要避免的一些做法
这篇文章 10 things to avoid in docker containers列举了一些在使用容器时需要避免的做法,包括:
- 不要在容器中保存数据(Don’t store data in containers)
- 将应用打包到镜像再部署而不是更新到已有容器(Don’t ship your application in two pieces)
- 不要产生过大的镜像 (Don’t create large images)
- 不要使用单层镜像 (Don’t use a single layer image)
- 不要从运行着的容器上产生镜像 (Don’t create images from running containers )
- 不要只是使用 “latest”标签 (Don’t use only the “latest” tag)
- 不要在容器内运行超过一个的进程 (Don’t run more than one process in a single container )
- 不要在容器内保存 credentials,而是要从外面通过环境变量传入 ( Don’t store credentials in the image. Use environment variables)
- 不要使用 root 用户跑容器进程(Don’t run processes as a root user )
- 不要依赖于 IP 地址,而是要从外面通过环境变量传入 (Don’t rely on IP addresses )
CONTAINER ID & NAMES
1 | mac@ubuntu:~/docker9$ docker ps |
- CONTAINER ID
随机生成,且全局唯一。 - NAMES
如果不使用--name
指定容器的名字,docker 将会随机生成一个名字;注意,这里的 NAMES 与 CONTAINER ID 是一一对应的,所以它也是全局唯一的
如何进入一个正在运行的容器并执行 bash 命令
先通过 docker ps 查看当前运行在 docker engine 上的 containers 有哪些,
1 | mac@ubuntu:~/docker9$ docker ps |
可以看到,总共有三个 containers 在执行,我们以 CONTAINER ID b0bee025fc87 为例看看如何进入该容器并执行 bash 命令?
通过 NAME 进入
1
2
3mac@ubuntu:~/docker9$ docker exec -it exposed_port_published bash
root@b0bee025fc87:/# exit
exit通过 CONTAINER ID 进入
1
2
3mac@ubuntu:~/docker9$ docker exec -it b0bee025fc87 bash
root@b0bee025fc87:/# exit
exit
Dockerfile
Docker build 官方文档就构建 Docker 镜像做了比较全面的阐述,这里就一些我比较关心的命令做深入的分析和总结。
ADD 和 COPY
ADD
1 | FROM ubuntu:14.04 |
- 将宿主机上的文件拷贝到容器,如果是压缩文件,将自动解压
- ADD localdir /root/dockerdir
将宿主机上的localdir
路径中的文件全部拷贝到 Docker 容器中的/root/dockerdir
中
注意两点,localdir
必须和Dockerfile
在同一个目录中,否则会找不到指定的路径localdir
dockerdir
如果在/root
路径中没有创建,将会自动创建
- ADD localdir/foo.tar.gz /root/dockerdir
注意,拷贝过程中将会自动解压,如果不希望被解压,使用COPY
- ADD localdir /root/dockerdir
- 将网络上的文件拷贝到容器
- ADD http://foo.com/bar.go /root/dockerdir
将 bar.go 拷贝到root/dockerdir
目录中。
- ADD http://foo.com/bar.go /root/dockerdir
测试过程,
我在包含Dockerfile
的文件夹内创建了localdir/helloworld
文件,将其拷贝到/root/dockerdir
中,来进行测试。
创建镜像,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15Sending build context to Docker daemon 3.584 kB
Step 1 : FROM ubuntu:14.04
---> 3f755ca42730
Step 2 : MAINTAINER mac Liu <mac.liu@unknow.com>
---> Running in 29628aeed6f0
---> a2174fd3f262
Removing intermediate container 29628aeed6f0
Step 3 : ADD localdir /root/dockerdir
---> c26364f10cc5
Removing intermediate container 1e9a3528dc32
Step 4 : CMD /bin/bash
---> Running in 116514d4bd1c
---> 58239e98659e
Removing intermediate container 116514d4bd1c
Successfully built 58239e98659e检查容器
1
2
3
4mac@ubuntu:~/docker1$ docker run -it test_add /bin/bash
root@b734658f162a:/# cd /root/dockerdir/
root@b734658f162a:~/dockerdir# ls
helloworld
COPY
使用的方式和ADD
基本一致,只是,如果是压缩文件的时候,如果不希望在拷贝过程中自动被解压,那么使用COPY
。
CMD 和 ENTRYPOINT
CMD
官网解释,
The main purpose of a CMD is to provide defaults for an executing container.
为一个执行中的容器提供默认的执行命令;
CMD 的三种用法如下,
1 | CMD ["executable","param1","param2"] (exec form, this is the preferred form) |
- 第一种用法:运行一个可执行的文件并提供参数。
- 第二种用法:为
ENTRYPOINT
指定参数。 - 第三种用法(shell form):是以
/bin/sh -c
的方法执行的命令。
CMD 用例剖析
如果在Dockerfile
中指定CMD [“/bin/echo”, “this is a echo test ”]
,那么在 build 后,运行容器,docker run ec
,假设镜像的名称为 ec,那么输出this is a echo test
。那么让我们来构建这个测试用例,
创建 Dockerfile
1
2
3FROM ubuntu:14.04
MAINTAINER mac Liu <mac.liu@unknow.com>
CMD ["/bin/echo", "this is a echo test"]创建镜像
1
mac@ubuntu:~/docker2$ docker build -t ec .
测试
1
2mac@ubuntu:~/docker2$ docker run ec
this is a echo test可见,
CMD ["/bin/echo", "this is a echo test"]
指定的就是 docker 容器启动后,默认执行的命令;
注意事项,
- 只有一个
CMD指令
生效,如果指定了多个CMD
指令,那么只有最后一个生效 如果
docker run
中指定了运行参数,那么会把CMD
命令给覆盖掉1
2mac@ubuntu:~/docker2$ docker run -it ec /bin/bash
root@642b4bb0a99e:/#可见,这里使用
/bin/bash
覆盖了CMD
指定的命令/bin/echo
ENTRYPOINT
官网解释
An ENTRYPOINT allows you to configure a container that will run as an executable,它让你的容器表现得像一个可执行的程序
一样。
可执行的程序?
下面举例来说明吧,
1 | FROM ubuntu:14.04 |
1 | mac@ubuntu:~/docker3$ docker build -t echo . |
将容器当做可执行程序
一样
1 | mac@ubuntu:~/docker3$ docker run -it echo "hello world" |
"hello world"
被解析成ENTRYPOINT
指定的命令/bin/echo
的执行参数,所以,直接打印出字符串“hello world”。
可能你会有疑问,难道CMD
不可以做一样的事情吗?那我们来试试吧,
1 | FROM ubuntu:14.04 |
1 | mac@ubuntu:~/docker2$ docker build -t echo_by_cmd . |
1 | mac@ubuntu:~/docker2$ docker run -it echo_by_cmd "hello world" |
很明显,这里将"hello world"
解析为执行命令,用来覆盖cmd
指定的命令,所以,无法找到"hello world"
这样一个命令,所以会报错!
ENTRYPOINT 的两种写法
1 | ENTRYPOINT ["executable", "param1", "param2"] (the preferred exec form) |
- ENTRYPOINT [“executable”, “param1”, “param2”] (the preferred exec form)
可执行的程序?使用的是第一种的exec
的写法,也是官方所推荐的写法,这里就不再继续阐述了; ENTRYPOINT command param1 param2 (shell form)
将会默认使用/bin/sh -c
来执行command
例一
1
2
3FROM ubuntu:14.04
MAINTAINER mac Liu <mac.liu@unknow.com>
ENTRYPOINT ps1
mac@ubuntu:~/docker4$ docker build -t ps .
1
2
3
4mac@ubuntu:~/docker4$ docker run -it ps
PID TTY TIME CMD
1 ? 00:00:00 sh
5 ? 00:00:00 ps这里执行的正好是
/bin/sh -c ps
,所以可以看到 PID = 1 的 CMD 为 sh,而 ps 正好是其子进程。例二,
CMD
和ENTRYPOINT
同时使用shell
的方式1
2
3
4FROM ubuntu:14.04
MAINTAINER mac Liu <mac.liu@unknow.com>
CMD top
ENTRYPOINT ps1
mac@ubuntu:~/docker5$ docker build -t both .
1
2
3
4mac@ubuntu:~/docker5$ docker run -it both
PID TTY TIME CMD
1 ? 00:00:00 sh
5 ? 00:00:00 psDocker 容器中执行的命令实际上是
/bin/sh -c ps /bin/sh -c top
,也就相当于执行的是ps
命令,1
2
3
4
5
6
7
8
9
10
11
12
13
14mac@ubuntu:~/docker5$ /bin/sh -c ps /bin/sh -c top
PID TTY TIME CMD
23176 pts/3 00:00:00 bash
23322 pts/3 00:00:00 sh
23323 pts/3 00:00:00 bash
26840 pts/3 00:00:00 sh
26841 pts/3 00:00:00 ps
mac@ubuntu:~/docker5$ /bin/sh -c ps
PID TTY TIME CMD
23176 pts/3 00:00:00 bash
23322 pts/3 00:00:00 sh
23323 pts/3 00:00:00 bash
26842 pts/3 00:00:00 sh
26843 pts/3 00:00:00 ps可见,docker 容器先执行 ENTRYPOINT 的 shell command,然后再执行 CMD 的 shell command
例三、CMD 作为 ENTRYPOINT 的参数
1
2
3
4FROM ubuntu:14.04
MAINTAINER comedsh <comedsh@unknow.com>
CMD ["-n", "10"]
ENTRYPOINT topCMD 指定的参数将作为 top 的执行参数
最佳实践是,ENTRYPOINT 指定不可变的参数,使用 CMD 指定可变参数
1
2
3FROM ubuntu:14.10
ENTRYPOINT ["top", "-b"]
CMD ["-c"]注意,这种方式必须使用
EXEC
的方式,如果 ENTRYPOINT 使用的是SHELL COMMAND
的方式,ENTRYPOINT 的命令指定的参数将会同时覆盖 docker run 里面所指定的参数和 CMD 所指定的参数。
WORKDIR
设置CMD
, ENTRYPOINT
, RUN
命令的当前执行路径;
RUN
1
2
3
4
5
6mac@ubuntu:~/docker8$ docker run --name temp -w /etc/fonts ubuntu:14.04 /bin/pwd
/etc/fonts
mac@ubuntu:~/docker8$ docker rm temp
temp
mac@ubuntu:~/docker8$ docker run --name temp --workdir /etc/fonts ubuntu:14.04 /bin/pwd
/etc/fontsCMD
/ENTRYPOINT
1
2
3FROM ubuntu:14.04
WORKDIR /etc/fonts
ENTRYPOINT pwd1
mac@ubuntu:~/docker9$ docker build -t workdir .
1
2mac@ubuntu:~/docker9$ docker run --name workdir_c workdir
/etc/fonts
ENV
设置环境变量
语法
1 | ENV <key> <value> |
1 | FROM ubuntu:14.04 |
生成镜像以后,其元数据如下,
1 | "Env": [ |
对应的容器也能看到对应的环境变量
1 | "Env": [ |
进入容器后,
1 | root@ba460e0e9dc4:/# echo $abc |
EXPOSE,以及 EXPOSE 与 docker run -p -P 之间的关系
EXPOSE 只是标记某个端口需要暴露出来
EXPOSE
仅仅只是标记某个端口需要被暴露出来;而并不是说,只要构建成功,容器运行,该端口就被PUBLISH
出来,并且可以使用了;(备注:PUBLISH
这里的意思就是,把容器EXPOSE
的端口与主机的某个端口进行映射,这样,就可以通过访问主机的端口访问到 Docker 的服务了。)
举个例子,
1 | FROM ubuntu:14.04 |
1 | mac@ubuntu:~/docker7$ docker build -t exposed_port . |
直接运行容器exposed_port_not_published
1 | mac@ubuntu:~/docker7$ docker run -d --name exposed_port_not_published exposed_port |
1 | mac@ubuntu:~/docker7$ docker ps |
1 | mac@ubuntu:~/docker7$ docker inspect exposed_port_not_published |
端口并没有与主机的端口进行任何的映射,可见EXPOSE
仅仅只是标识一个port 8888
需要被PUBLISH
出来,并不会在容器启动的时候PUBLISH
。
使用 docker run -P 使其暴露出来
1 | mac@ubuntu:~/docker7$ docker run -d --name exposed_port_published -P exposed_port |
1 | mac@ubuntu:~/docker7$ docker ps |
可见主机使用一个随机端口 32770 与容器的 8888 端口进行了映射,0.0.0.0:32770
->8888/tcp
;
1 | "NetworkSettings": { |
1 | mac@ubuntu:~/docker7$ telnet 0.0.0.0 32770 |
使用 docker run -p 自定义暴露方式
1 | mac@ubuntu:~/docker7$ docker run -d --name exposed_port_self_published -p 8888:8888 exposed_port |
1 | mac@ubuntu:~/docker7$ docker ps |
0.0.0.0:8888->8888/tcp
,可见是按照自定义的方式进行的映射
1 | mac@ubuntu:~/docker7$ telnet 0.0.0.0 8888 |
USER
语法
1 | # Usage: USER [UID|USERNAME] |
默认用户
Dockerfile 中执行 RUN 命令所使用的用户,默认是继承自基础镜像中的用户,例如FROM ubuntu:14.04
,那么默认使用的是镜像ubuntu:14.04
中的用户;当然,如果你使用的基础镜像是使用的非 root 用户,那么在 Dockerfile 构建过程中使用的也就是非 root 用户
自定义用户
如果你需要新建一个用户来执行 Docker 容器,可以先创建RUN groupadd -r macgroup && useradd -r -g macgroup mac
;
举例,使用mac
用户来执行top
命令
1 | FROM ubuntu:14.04 |
1 | mac@ubuntu:~/docker8$ docker build -t spec_user . |
1 | mac@ubuntu:~/docker8$ docker run --name spec_user_c spec_user |
可以看到,执行的用户是mac
。
MAINTAINER
设置镜像的作者
1 | # Usage: MAINTAINER [name] |
RUN
由前面的通过 docker build 进行构建的分析可知,RUN
命令在构建过程中的临时容器中执行相关的 shell 命令;Docker 将改动保存到当前容器中,随后 commit 生成一个中间镜像。
VOLUMN
允许容器访问host上某个目录
1 | # Usage: VOLUME ["/dir_1", "/dir_2" ..] |
HEALTHCHECK
定期检查容器的运行状态,语法HEALTHCHECK [OPTIONS] CMD command
,举例
1 | FROM ubuntu:14.04 |
HEALTHCHECK --interval=10s --timeout=2s CMD curl -f http://localhost:8888/ || exit 1
每隔10秒执行一次CMD
命令,CMD
通过curl
执行 HTTP 操作来检测 docker 的8888
服务是否正常。
1 | mac@ubuntu:~/docker10$ docker build -t checkhealth . |
1 | mac@ubuntu:~/docker10$ docker run --name checkhealth_c checkhealth |
可以看到,每隔 10 秒,通过HEALTHCHECK
输出curl
的执行结果,表示docker
所暴露的服务是正常的。同样,我们可以通过docker ps
来查看当前容器的健康状态
1 | mac@ubuntu:~$ docker ps |
从STATUS
上可以看到docker
当前的健康状态。