上篇文章《Docker快速入门(一)》介绍了docker的基本概念和image的相关操作,本篇将进一步介绍image,容器和Dockerfile。
1 image文件
(1)Docker 把应用程序及其依赖,打包在 image 文件里面。
(2)只有通过这个image文件,才能生成 Docker 容器。image 文件可以看作是容器的模板。Docker 根据 image 文件生成容器的实例。
(3)同一个 image 文件,可以生成多个同时运行的容器实例。
(4)image 是二进制文件。实际开发中,一个 image 文件往往通过继承另一个 image 文件,加上一些个性化设置而生成。
(5)image 文件是通用的,一台机器的 image 文件拷贝到另一台机器,照样可以使用。
(6)一般来说,为了节省时间,我们应该尽量使用别人制作好的 image 文件,而不是自己制作。即使要定制,也应该基于别人的 image 文件进行加工,而不是从零开始制作。
(7)为了方便共享,image 文件制作完成后,可以上传到网上的仓库。Docker 的官方仓库 Docker Hub 是最重要、最常用的 image 仓库。此外,出售自己制作的 image 文件也是可以的。
下面我们举例介绍几个命令:
docker container run hello-world
该命令会根据 image 文件,生成一个正在运行的容器实例。
注意:docker container run命令具有自动抓取 image 文件的功能。如果发现本地没有指定的 image 文件,就会从仓库自动抓取。因此,前面的docker image pull命令并不是必需的步骤。
如果运行成功,如下:
[@sjs_123_183 ~]# docker container run hello-world Hello from Docker!
This message shows that your installation appears to be working correctly. To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal. To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash Share images, automate workflows, and more with a free Docker ID:
https://cloud.docker.com/ For more examples and ideas, visit:
https://docs.docker.com/engine/userguide/ [@sjs_123_183 ~]#
输出以上内容后,hello world就会停止运行,容器自动终止。有些容器提供服务,并不会自动终止,如Ubuntu的image:
[@sjs_123_183 ~]# docker container run -it ubuntu bash
root@cd902f829884:/#
root@cd902f829884:/# pwd
/
root@cd902f829884:/#
这时候,我们打开另一个终端,通过:
docker container ls
可以看到本机正在运行的容器实例:
[@sjs_123_183 ~]# docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cd902f829884 ubuntu "bash" 50 seconds ago Up 50 seconds nifty_pike
此时通过
docker container kill [CONTAINER ID]
该命令可以终止正在运行的容器。如下:
[@sjs_123_183 ~]# docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cd902f829884 ubuntu "bash" 4 minutes ago Up 4 minutes nifty_pike
[@sjs_123_183 ~]# docker container kill cd9
cd9
2 容器文件
image 文件生成的容器实例,本身也是一个文件,称为容器文件。也就是说,一旦容器生成,就会同时存在两个文件: image 文件和容器文件。而且关闭容器并不会删除容器文件,只是容器停止运行而已。
docker container kill [CONTAINER ID] # 列出正在运行的容器
docker container ls --all # 列出本机所有容器,包括终止运行的容器
如下:
[@sjs_123_183 ~]# docker container ls --all
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cd902f829884 ubuntu "bash" 6 minutes ago Exited (137) About a minute ago nifty_pike
3ca03ca1cd7c hello-world "/hello" 11 minutes ago Exited (0) 11 minutes ago laughing_booth
d225defb10db ubuntu "bash" 13 minutes ago Exited (137) 13 minutes ago fervent_galileo
77182065e27d ubuntu "bash" 16 minutes ago Exited (127) 15 minutes ago ecstatic_kilby
ebf4e2421f51 hello-world "/hello" 20 minutes ago Exited (0) 20 minutes ago heuristic_albattani
081ccb2d6eed nginx "nginx -g 'daemon of…" 4 weeks ago Exited (0) 4 weeks ago adoring_poincare
fea01895c580 hello-world "/hello" 4 weeks ago Exited (0) 4 weeks ago vibrant_goldwasser
命令的输出结果之中,包括容器的 ID, 即CONTAINER ID。很多地方都需要提供这个 ID,比如上一节终止容器运行的docker container kill命令。
终止运行的容器文件,依然会占据硬盘空间,可以使用docker container rm [CONTAINER ID]命令删除。参见:
[@sjs_123_183 ~]# docker container ls --all
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cd902f829884 ubuntu "bash" 6 minutes ago Exited (137) About a minute ago nifty_pike
3ca03ca1cd7c hello-world "/hello" 11 minutes ago Exited (0) 11 minutes ago laughing_booth
d225defb10db ubuntu "bash" 13 minutes ago Exited (137) 13 minutes ago fervent_galileo
77182065e27d ubuntu "bash" 16 minutes ago Exited (127) 15 minutes ago ecstatic_kilby
ebf4e2421f51 hello-world "/hello" 20 minutes ago Exited (0) 20 minutes ago heuristic_albattani
081ccb2d6eed nginx "nginx -g 'daemon of…" 4 weeks ago Exited (0) 4 weeks ago adoring_poincare
fea01895c580 hello-world "/hello" 4 weeks ago Exited (0) 4 weeks ago vibrant_goldwasser
[@sjs_123_183 ~]# docker container rm 3ca ebf
3ca
ebf
[@sjs_123_183 ~]# docker container ls --all
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cd902f829884 ubuntu "bash" 10 minutes ago Exited (137) 5 minutes ago nifty_pike
d225defb10db ubuntu "bash" 17 minutes ago Exited (137) 17 minutes ago fervent_galileo
77182065e27d ubuntu "bash" 20 minutes ago Exited (127) 19 minutes ago ecstatic_kilby
081ccb2d6eed nginx "nginx -g 'daemon of…" 4 weeks ago Exited (0) 4 weeks ago adoring_poincare
fea01895c580 hello-world "/hello" 4 weeks ago Exited (0) 4 weeks ago vibrant_goldwasser
上面的例子是我们通过容器ID,删除两个hello-world容器文件。
3 编写Dockerfile
学会使用 image 文件以后,接下来的问题就是,如何可以生成 image 文件?如果你要推广自己的软件,势必要自己制作 image 文件。这就需要用到 Dockerfile 文件。它是一个文本文件,用来配置 image。Docker 根据 该文件生成二进制的 image 文件。镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,可以重复的使用、镜像构建透明。Dockerfile 是一个文本文件,其内包含了一条条的指令(Instruction),每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。
以 nginx 镜像为例,这次我们使用 Dockerfile 来定制。
mkdir -p /search/odin/xnginx # 新建一个目录
cd /search/odin/xnginx # cd 到该目录下
touch Dockerfile # 创建一个叫Dockerfile的文件
在Dockerfile中写入下面两行,保存退出:
FROM nginx
RUN echo '<h1>Hello, Docker! Hello, xnginx!</h1>' > /usr/share/nginx/html/index.html
这个Dockerfile很简单,涉及到了两条指令,FROM
和 RUN。
(1)FROM指定基础镜像
定制镜像,那一定是以一个镜像为基础,在其上进行定制。就像我们之前运行了一个 nginx 镜像的容器,再进行修改一样,基础镜像是必须指定的。
而 FROM 就是指定基础镜像,因此一个 Dockerfile 中 FROM 是必备的指令,并且必须是第一条指令。
在 Docker Store 上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginx、redis、mongo、mysql、httpd、php、tomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 node、openjdk、python、ruby、golang 等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。
如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如 ubuntu、debian、centos、fedora、alpine 等,这些操作系统的软件库为我们提供了更广阔的扩展空间。
除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。
FROM scratch
...
以 scratch 为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。
不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见,比如 swarm、coreos/etcd。对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接 FROM scratch 会让镜像体积更加小巧。使用 Go 语言 开发的应用很多会使用这种方式来制作镜像,这也是为什么有人认为 Go 是特别适合容器微服务架构的语言的原因之一。
(2)RUN 执行命令
RUN 指令是用来执行命令行命令的。由于命令行的强大能力,RUN 指令在定制镜像时是最常用的指令之一。其格式有两种:
格式一:
shell 格式:RUN <命令>,就像直接在命令行中输入的命令一样。刚才写的 Dockerfile 中的 RUN 指令就是这种格式。
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html 格式二:
exec 格式:RUN ["可执行文件", "参数1", "参数2"],这更像是函数调用中的格式。
RUN 就像 Shell 脚本一样可以执行命令,很多初学者在写Dockerfile的时候会像Shell 脚本一样把每个命令对应一个 RUN,比如这样:
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 层镜像。这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。
正确的写法是:
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
(1)之前所有的命令只有一个目的,就是编译、安装 redis 可执行文件。
因此没有必要建立很多层,这只是一层的事情。这里没有使用很多个 RUN 对一一对应不同的命令,而是仅仅使用一个 RUN 指令,并使用 && 将各个所需命令串联起来。将之前的 7 层,简化为了 1 层。在撰写 Dockerfile 的时候,要经常提醒自己,这并不是在写 Shell 脚本,而是在定义每一层该如何构建。
(2)这里为了格式化还进行了换行。
Dockerfile 支持 Shell 类的行尾添加 \ 的命令换行方式,以及行首 # 进行注释的格式。良好的格式,比如换行、缩进、注释等,会让维护、排障更为容易,这是一个比较好的习惯。
(3)还可以看到这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了 apt 缓存文件。
这是很重要的一步,我们之前说过,镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。
很多人初学 Docker 制作出了很臃肿的镜像的原因之一,就是忘记了每一层构建的最后一定要清理掉无关文件。
4 构建镜像
到目前为止,我们明白了这个 Dockerfile 的内容,接下来就让我们构建这个镜像吧。在 Dockerfile
文件所在目录执行:
docker build -t nginx:v2 .
详细如下:
[@sjs_123_183 xnginx]# docker build -t nginx:v2 .
Sending build context to Docker daemon 2.048kB
Step 1/2 : FROM nginx
---> c5c4e8fa2cf7
Step 2/2 : RUN echo '<h1>Hello, Docker! Hello, xnginx!</h1>' > /usr/share/nginx/html/index.html
---> Running in e955070ac2c9
Removing intermediate container e955070ac2c9
---> 1beca7b40dee
Successfully built 1beca7b40dee
Successfully tagged nginx:v2
[@sjs_123_183 xnginx]#
从命令的输出结果中,我们可以清晰的看到镜像的构建过程。
在 Step 2 中,如同我们之前所说的那样,RUN 指令启动了一个容器 e955070ac2c9,执行了所要求的命令,并最后提交了这一层 1beca7b40dee,随后删除了所用到的这个容器 e955070ac2c9。
这里我们使用了 docker build 命令进行镜像构建。其格式为:
docker build [选项] <上下文路径/URL/->
这里我们指定了最终镜像的名称 -t nginx:v2,现在让我们启动自己构建的容器:
docker run --name webserver -d -p 80:80 nginx:v2
这条命令会用 nginx 镜像启动一个容器,命名为 webserver,并且映射了 80 端口,这样我们可以用curl 命令去访问这个 nginx 服务器。详情如下:
[@sjs_123_183 ~]# docker run --name webserver -d -p 80:80 nginx:v2
a9f012a96d98262bffd30286c9d23dfe929b032c2149fa67105d29ddde71b763
[@sjs_123_183 ~]# curl http://127.0.0.1:80
<h1>Hello, Docker! Hello, xnginx!</h1>
[@sjs_123_183 ~]#
通过浏览器也是可以访问的,如果你是本机的浏览器需要访问:http://localhost。此处由于我在远程的linux上,只需要访问ip即可。
5 上下文路径
细心的同学可能已经注意到docker build 命令最后有一个 . 号。. 表示当前目录,而 Dockerfile 就在当前目录,因此不少初学者以为这个路径是在指定 Dockerfile 所在路径,这么理解其实是不准确的。
前文已经说到docker build 的命令格式,这个 . 号是指定上下文路径。那么什么是上下文呢?
(1)首先我们要理解 docker build 的工作原理。Docker 在运行时分为 Docker 引擎(也就是服务端守护进程)和客户端工具。Docker 的引擎提供了一组 REST API,被称为 Docker Remote API,而如 docker 命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。
因此,虽然表面上我们好像是在本机执行各种 docker 功能,但实际上,一切都是使用的远程调用形式在服务端(Docker 引擎)完成。也因为这种 C/S 设计,让我们操作远程服务器的 Docker 引擎变得轻而易举。
(2)当我们进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。而 docker build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?
这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。
如果在 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:v2 . 中的这个 .,实际上是在指定上下文的目录,docker build 命令会将该目录下的内容打包交给 Docker 引擎以帮助构建镜像。
在docker build命令的输出内容中有这么一句:
Sending build context to Docker daemon 2.048kB
这实际上就是发送上下文的过程。
(1)理解构建上下文对于镜像构建是很重要的,避免犯一些不应该的错误。
比如有些初学者在发现 COPY /opt/xxxx /app 不工作后,于是干脆将 Dockerfile 放到了硬盘根目录去构建,结果发现 docker build 执行后,在发送一个几十 GB 的东西,极为缓慢而且很容易构建失败。那是因为这种做法是在让 docker build 打包整个硬盘,这显然是使用错误。
(2)正确的做法是,将 Dockerfile 置于一个空目录下,或者项目根目录下。
如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。
(3)那么为什么会有人误以为 . 是指定 Dockerfile 所在目录呢?
这是因为在默认情况下,如果不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile。
这只是默认行为,实际上 Dockerfile 的文件名并不要求必须为 Dockerfile,而且并不要求必须位于上下文目录中,比如可以用 -f ../Dockerfile.php 参数指定某个文件作为 Dockerfile。
当然,一般大家习惯性的会使用默认的文件名 Dockerfile,以及会将其置于镜像构建上下文目录中。