1.2 构建一个Docker应用程序
现在,我们要动手使用Docker来构建一个简单的“to-do”应用程序(todoapp)镜像了。在这个过程中,读者会看到一些关键的Docker功能,如Dockerfile、镜像复用、端口公开及构建自动化。这是接下来10分钟读者将学到的东西:
- 如何使用Dockerfile来创建Docker镜像;
- 如何为Docker镜像打标签以便引用;
- 如何运行新建的Docker镜像。
to-do应用是协助用户跟踪待完成事项的一个应用程序。我们所构建的应用将存储并显示可被标记为已完成的信息的简短字符串,它以一个简单的网页界面呈现。图1-6展示了如此操作将得到的结果。
图1-6 构建一个Docker应用程序
应用程序的细节不是重点。我们将演示的是,读者可以从我们所提供的一个简短的Dockerfile放心地在自己的宿主机上使用与我们相同的方法构建、运行、停止和启动一个应用程序,而无须考虑应用程序的安装或依赖。这正是 Docker为我们提供的关键部分——可靠地重现并简便地管理和共享开发环境。这意味着用户无须再遵循并迷失在那些复杂的或含糊的安装说明中。
注意
这个to-do应用程序将贯穿本书,多次使用,它非常适合用于实践和演示,因此值得读者熟悉一下。
1.2.1 创建新的Docker镜像的方式
创建Docker镜像有4种标准的方式。表1-2逐一列出了这些方法。
表1-2 创建Docker镜像的方式
如果用户所做的是概念验证以确认安装过程是否正常,那么第一种“手工”方式是没问题的。在这个过程中,用户应对所采取的步骤做记录,以便在需要时回到同一点上。
到某个时间点,用户会想要定义创建镜像的步骤。这就是Dockerfile方式(也就是我们这里所用的方式)。
对于更复杂的构建,用户需要使用第三种方式,特别是在Dockerfile功能还不足以满足镜像要求的时候。
最后一种方式从一个空镜像开始,通过叠加一组运行镜像所需要的文件进行构建。如果用户想导入一组在其他地方创建好的自包含的文件,这将非常有用,不过这种方法在主流应用中非常罕见。
现在,我们来看一下Dockerfile方法,其他方法将在本书后面再做说明。
1.2.2 编写一个Dockerfile
Dockerfile是一个包含一系列命令的文本文件。本示例中我们将使用的Dockerfile如代码清单1-1所示。创建一个新目录,移动到这个目录里,然后使用这些内容创建一个名为“Dockerfile”的文件。
代码清单1-1 todoapp Dockerfile
FROM node ⇽--- 定义基础镜像
LABEL maintainer ian.miell@gmail.com ⇽--- 声明维护人员
RUN git clone -q https://github.com/docker-in-practice/todo.git ⇽--- 克隆todoapp代码
WORKDIR todo ⇽--- 移动到新的克隆目录
RUN npm install > /dev/null ⇽--- 执行node包管理器的安装命令(npm)
EXPOSE 8000 ⇽--- 指定从所构建的镜像启动的容器需要监听这个端口
CMD ["npm","start"] ⇽--- 指定在启动时需要执行的命令
Dockerfile的开始部分是使用FROM
命令定义基础镜像。本示例使用了一个Node.js镜像以便访问Node.js程序。官方的Node.js镜像名为node
。
接下来,使用LABEL
命令声明维护人员。在这里,我们使用的是其中一个人的电子邮件地址,读者也可以替换成自己的,因为现在它是你的Dockerfile了。这一行不是创建可工作的Docker镜像所必需的,不过将其包含进来是一个很好的做法。到这个时候,构建已经继承了node容器的状态,读者可以在它上面做操作了。
接下来,使用RUN
命令克隆todoapp代码。这里使用指定的命令获取应用程序的代码:在容器内运行git
。在这个示例中,Git是安装在基础node镜像里的,不过读者不能对这类事情做假定。
现在使用WORKDIR
命令移动到新克隆的目录中。这不仅会改变构建环境中的目录,最后一条WORKDIR
命令还决定了从所构建镜像启动容器时用户所处的默认目录。
接下来,执行node包管理器的安装命令(npm
)。这将为应用程序设置依赖。我们对输出的信息不感兴趣,所以将其重定向到/dev/null上。
由于应用程序使用了8000端口,使用EXPOSE
命令告诉Docker从所构建镜像启动的容器应该监听这个端口。
最后,使用CMD
命令告诉Docker在容器启动时将执行哪条命令。
这个简单的示例演示了Docker及Dockerfile的几个核心功能。Dockerfile是一组严格按顺序执行的有限的命令集的简单序列。它影响了最终镜像的文件和元数据。这里的RUN
命令通过签出并安装应用程序影响了文件系统,而EXPOSE
、CMD
和WORKDIR
命令影响了镜像的元数据。
1.2.3 构建一个Docker镜像
读者已经定义了自己的Dockerfile的构建步骤。现在可以键入图1-7所示的命令,从而构建Docker镜像了。
图1-7 docker build子命令
输出看起来和下面类似。
Sending build context to Docker daemon 2.048kB ⇽--- Docker会上传docker build指定目录下的文件和目录
Step 1/7 : FROM node ⇽--- 每个构建步骤从 1 开始按顺序编号,并与命令一起输出
---> 2ca756a6578b ⇽--- 每个命令会导致一个新镜像被创建,其镜像ID在此输出
Step 2/7 : LABEL maintainer ian.miell@gmail.com
---> Running in bf73f87c88d6
---> 5383857304fc
Removing intermediate container bf73f87c88d6 ⇽--- 为节省空间,在继续前每个中间容器会被移除
Step 3/7 : RUN git clone -q https://github.com/docker-in-practice/todo.git
---> Running in 761baf524cc1
---> 4350cb1c977c
Removing intermediate container 761baf524cc1
Step 4/7 : WORKDIR todo
---> a1b24710f458
Removing intermediate container 0f8cd22fbe83
Step 5/7 : RUN npm install > /dev/null
---> Running in 92a8f9ba530a
npm info it worked if it ends with ok ⇽--- 构建的调试信息在此输出(限于篇幅,本代码清单做了删减)
[...]
npm info ok
---> 6ee4d7bba544
Removing intermediate container 92a8f9ba530a
Step 6/7 : EXPOSE 8000
---> Running in 8e33c1ded161
---> 3ea44544f13c
Removing intermediate container 8e33c1ded161
Step 7/7 : CMD npm start
---> Running in ccc076ee38fe
---> 66c76cea05bb
Removing intermediate container ccc076ee38fe
Successfully built 66c76cea05bb ⇽--- 此次构建的最终镜像ID,可用于打标签
现在,拥有了一个具有镜像ID(前面示例中的“66c76cea05bb”,不过读者的ID会不一样)的Docker镜像。总是引用这个ID会很麻烦,可以为其打标签以方便引用,如图1-8所示。
图1-8 docker tag
子命令
输入图1-8所示的命令,将66c76cea05bb替换成读者生成的镜像ID。
现在就能从一个Dockerfile构建自己的Docker镜像副本,并重现别人定义的环境了!
1.2.4 运行一个Docker容器
读者已经构建出Docker镜像并为其打上了标签。现在可以以容器的形式来运行它了。运行后的输出结果如代码清单1-2所示。
代码清单1-2 todoapp的docker run输出
$ docker run -i -t -p 8000:8000 --name example1 todoapp ⇽--- docker run子命令启动容器,-p将容器的 8000 端口映射到宿主机的8000端口上,--name给容器赋予一个唯一的名字,最后一个参数是镜像
npm install
npm info it worked if it ends with ok
npm info using npm@2.14.4
npm info using node@v4.1.1
npm info prestart todomvc-swarm@0.0.1
> todomvc-swarm@0.0.1 prestart /todo ⇽--- 容器的启动进程的输出被发送到终端中
> make all
npm install
npm info it worked if it ends with ok
npm info using npm@2.14.4
npm info using node@v4.1.1
npm WARN package.json todomvc-swarm@0.0.1 No repository field.
npm WARN package.json todomvc-swarm@0.0.1 license should be a valid SPDX
➥ license expression
npm info preinstall todomvc-swarm@0.0.1
npm info package.json statics@0.1.0 license should be a valid SPDX license
➥ expression
npm info package.json react-tools@0.11.2 No license field.
npm info package.json react@0.11.2 No license field.
npm info package.json node-
jsx@0.11.0 license should be a valid SPDX license expression
npm info package.json ws@0.4.32 No license field.
npm info build /todo
npm info linkStuff todomvc-swarm@0.0.1
npm info install todomvc-swarm@0.0.1
npm info postinstall todomvc-swarm@0.0.1
npm info prepublish todomvc-swarm@0.0.1
npm info ok
if [ ! -e dist/ ]; then mkdir dist; fi
cp node_modules/react/dist/react.min.js dist/react.min.js
LocalTodoApp.js:9: // TODO: default english version
LocalTodoApp.js:84: fwdList = this.host.get('/TodoList#'+listId);
// TODO fn+id sig
TodoApp.js:117: // TODO scroll into view
TodoApp.js:176: if (i>=list.length()) { i=list.length()-1; } // TODO
➥ .length
local.html:30: <!-- TODO 2-split, 3-split -->
model/TodoList.js:29: // TODO one op - repeated spec? long spec?
view/Footer.jsx:61: // TODO: show the entry's metadata
view/Footer.jsx:80: todoList.addObject(new TodoItem()); // TODO
➥ create default
view/Header.jsx:25: // TODO list some meaningful header (apart from the
➥ id)
npm info start todomvc-swarm@0.0.1
> todomvc-swarm@0.0.1 start /todo
> node TodoAppServer.js
Swarm server started port 8000
^Cshutting down http-server... ⇽--- 在此按组合键Ctrl+C终止进程和容器
closing swarm host...
swarm host closed
npm info lifecycle todomvc-swarm@0.0.1~poststart: todomvc-swarm@0.0.1
npm info ok
$ docker ps -a ⇽--- 执行这个命令查看已经启动和移除的容器,以及其ID和状态(就像进程一样)
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b9db5ada0461 todoapp "npm start" 2 minutes ago Exited (0) 2 minutes ago
➥ example1
$ docker start example1 ⇽--- 重新启动容器,这次是在后台运行
example1
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS
➥ PORTS NAMES
b9db5ada0461 todoapp "npm start" 8 minutes ago Up 10 seconds
➥ 0.0.0.0:8000->8000/tcp example1 ⇽--- 再次执行ps命令查看发生变化的状态
$ docker diff example1 ⇽--- docker diff子命令显示了自镜像被实例化成一个容器以来哪些文件受到了影响
C /root
C /root/.npm
C /root/.npm/_locks
C /root/.npm/anonymous-cli-metrics.json
C /todo ⇽--- 修改了/todo目录(C)
A /todo/.swarm ⇽--- 增加了/todo/.swarm目录(A)
A /todo/.swarm/_log
A /todo/dist
A /todo/dist/LocalTodoApp.app.js
A /todo/dist/TodoApp.app.js
A /todo/dist/react.min.js
C /todo/node_modules
docker run
子命令启动容器。-p
标志将容器的8000端口映射到宿主机的8000端口上,读者现在应该可以使用浏览器访问http://localhost:8000来查看这个应用程序了。--name
标志赋予了容器一个唯一的名称,以便后面引用。最后的参数是镜像名称。
一旦容器启动,我们就可以按组合键Ctrl+C终止进程和容器。读者可以执行ps
命令查看被启动且未被移除的容器。注意,每个容器都具有自己的容器 ID 和状态,与进程类似。它的状态是Exited
(已退出),不过读者可以重新启动它。这么做之后,注意状态已经改变为Up
(运行中),且容器到宿主机的端口映射现在也显示出来了。
docker diff
子命令显示了自镜像被实例化成一个容器以来哪些文件受到了影响。在这个示例中,todo目录被修改了(C),而其他列出的文件是新增的(A)。没有文件被删除(D),这是另一种可能性。
如读者所见,Docker“包含”环境的事实意味着用户可以将其视为一个实体,在其上执行的动作是可预见的。这赋予了Docker宽广的能力——用户可以影响从开发到生产再到维护的整个软件生命周期。这种改变正是本书所要描述的,在实践中展示Docker所能完成的东西。
接下来读者将了解Docker的另一个核心概念——分层。
1.2.5 Docker分层
Docker分层协助用户管理在大规模使用容器时会遇到的一个大问题。想象一下,如果启动了数百甚至数千个to-do应用,并且每个应用都需要将文件的一份副本存储在某个地方。
可想而知,磁盘空间会迅速消耗光!默认情况下,Docker在内部使用写时复制(copy-on-write)机制来减少所需的硬盘空间量(见图 1-9)。每当一个运行中的容器需要写入一个文件时,它会通过将该项目复制到磁盘的一个新区域来记录这一修改。在执行Docker提交时,这块磁盘新区域将被冻结并记录为具有自身标识符的一个层。
图1-9 启动时复制与写时复制对比
这一部分解释了Docker容器为何能如此迅速地启动——它们不需要复制任何东西,因为所有的数据已经存储为镜像。
提示
写时复制是计算技术中使用的一种标准的优化策略。在从模板创建一个新的(任意类型)对象时,只在数据发生变化时才能将其复制进来,而不是复制整个所需的数据集。依据用例的不同,这能省下相当可观的资源。
图1-10展示了构建的to-do应用,它具有我们所感兴趣的3个层。因为层是静态的,所以如果用户需要更改更高层上的任何东西,都可以在想引用的镜像之上进行构建。在这个to-do应用中,我们从公开可用的node镜像构建,并将变更叠加在最上层。
所有这3个层都可以被多个运行中的容器共享,就像一个共享库可以在内存中被多个运行中的进程共享一样。对于运维人员来说,这是一项至关重要的功能,可以在宿主机上运行大量基于不同镜像的容器,而不至于耗尽磁盘空间。
想象一下,将所运行的to-do应用作为在线服务提供给付费用户。你可以将服务扩散给大量用户。如果是在开发中,你可以一次在本地机器上启动多个不同的环境。如果是在进行测试,你可以比之前同时运行更多测试,速度也更快。有了分层,所有这些东西都成为可能。
图1-10 Docker中to-do应用的文件系统分层
通过使用Docker构建和运行一个应用程序,读者开始见识到Docker能给工作流带来的威力。重现并共享特定的环境,并能在不同的地方落地,让开发过程兼具灵活性和可控性。