参考文章:[深入浅出 Docker]
简介
容器是为应用而生,具体来说,容器能够简化应用的构建,部署和运行过程。
完整的应用容器化过程主要分为以下几个步骤:
- 编写应用代码
- 创建一个
Dockerfile
,其中包括当前应用的描述,依赖以及该如何运行这个应用 - 对该
Dockerfile
执行docker image build
命令 - 等待 Docker 将应用程序构建到 Docker 镜像中。
一旦应用容器化完成(即应用被打包成一个 Docker 镜像),就能以镜像的形式交付并以容器的方式运行了。
详解
单体应用容器化
接下来通过以下几个步骤,来介绍具体的过程:
- 获取应用代码
- 分析 Dockerfile
- 构建应用镜像
- 运行该应用
- 测试应用
- 容器应用化细节
- 生产环境中的多阶段构建
- 最佳实践
获取应用代码
应用代码可以从 GitHub 获取,连接为 psweb,将代码 clone 到本地
1 | $ git clone https://github.com/nigelpoulton/psweb.git |
克隆操作会创建一个名为 psweb
的文件夹,该目录下包含了全部的应用源码,以及包含界面和单元测试的子目录。
分析 Dockerfile
在代码目录中,有个名为 Dockerfile
的文件。这个文件包含了对当前应用的描述,并且能指导 Docker 完成镜像的构建。
在 Docker 中,包含应用文件的目录通常被称为构建上下文(Build Context)
。通常将 Dockerfile
放到构建上下文的根目录下。
另外很重要的一点是,文件开头字母是大写 D
,这里是一个单词。像 “dockerfile
“ 或者 “Docker file
“ 这种写法是不允许的。
接下来了解一下 Dockerfile
文件当中包含哪些具体内容:
1 | $ cat Dockerfile |
Dockerfile 主要包扩两个用途:
- 对当前应用的描述
- 指导 Docker 完成应用的容器化(创建一个包含当前应用的镜像)
下面是这个文件中的一些关键性步骤概述:
- 以 apline 镜像作为当前镜像基础
- 指定维护者 (maintainer) 为 “nigelpoulton@hotmail.com“
- 安装 Node.js 和 NPM
- 将应用的代码复制到镜像当中
- 设置新的工作目录
- 安装依赖包
- 记录应用的网络端口
- 将 app.js 设置为默认运行的应用
具体分析一下每一步的作用:
每个 Dockerfile 文件的第一行都是
FROM
指令。FROM
指令指定的镜像,会作为当前镜像的一个基础镜像层,当前应用的剩余内容会作为新增镜像层添加到基础镜像层之上。接下来,Dockerfile 中通过
LABEL
(标签)方式指定了当前镜像的维护者为 “nigelpoulton@hotmail.com
“。每个标签其实是一个键值对(Key-Value
),在一个镜像当中可以通过增加标签的方式来为镜像添加自定义元数据。RUN
指令会在FROM
指定的alpine
基础镜像之上,新建一个镜像层来存储这些安装内容。RUN apk add --update nodejs nodejs-npm
指令使用alpine
的apk
包管理器将nodejs
和nodejs-npm
安装到当前镜像之中。COPY . /src
指令将应用相关文件从构建上下文复制到了当前镜像中,并且新建一个镜像层来存储。下一步
Dockerfile
通过WORKDIR
指令,为 Dockerfile 中尚未执行得指令设置工作目录。该目录与镜像相关,并且会作为元数据记录到镜像配置中,但不会创建新的镜像层。然后,
RUN npm install
指令会根据package.json
中的配置信息,使用npm
来安装当前应用的相关依赖包。npm 命令会在前文设置的工作目录中执行,并且在镜像中新建镜像层来保存相应的依赖文件。因为当前应用需要通过端口
8080
对外提供一个 web 服务,所以在 Dockerfile 中通过EXPOSE 8080
指令来完成相应端口的设置。这个配置信息会作为镜像的元数据被保存下来,并不会产生新的镜像层。最终,通过
ENTRYPOINT
指令来指定当前镜像的入口程序。ENTRYPOINT
指定的配置信息也是通过镜像元数据的形式保存下来,而不是新增镜像层。
容器化当前应用/构建具体的镜像
下面的命令会构建并生成一个名为 web:latest
的镜像。命令最后的点(.
) 表示 Docker 在进行构建的时候,使用当前目录作为构建上下文。
一定要在命令最后包含这个点,并且在执行命令前,要确认当前目录是 psweb
(包含Dockerfile 和应用代码的目录)。
1 | $ docker image build -t web:latest . |
命令执行结束后,检查本地 Docker 镜像库是否包含了刚才构建的镜像
1 | $ docker image ls |
使用 docker image inspect web:latest
来确认刚刚构建的镜像配置是否正确。这个命令会列出 Dockerfile 中设置的所有配置项。
推送镜像到仓库
在创建一个镜像之后,将其保存在一个镜像仓库服务是一个不错的方式。这样存储镜像会比较安全,并且可以被其他人访问使用。Docker Hub 就是这样的一个开放的公共镜像仓库服务,并且这也是 docker image push
命令默认的推送地址。
在推送镜像之前,需要先使用 Docker ID 登录 Docker Hub。除此之外,还需要为待推送的镜像打上合适的标签。
- 登录 Docker Hub
1 | $ docker login |
推送镜像之前,还需要为镜像打标签。这是因为 Docker 在镜像推送的过程中需要如下信息:
- Registry(镜像仓库服务)
- Repository(镜像仓库)
- Tag(镜像标签)
我们无需为 Registry 和 Tag 指定值,当我们没有为上述信息指定具体值的时候,Docker 会默认 Registry=docker.io,Tag=latest。但是 Docker 并没有给 Repository 提供默认值,而是从被推送镜像中的 REPOSITORY 属性值获取。
在前面的例子中执行了 docker image ls
命令。在该命令的输出内容中可以看到,镜像仓库的名称是 web
。这意味着执行 docker image push
命令,会尝试将镜像推送到 docker.io/web:latest
中。但是其实 zx19904227511
这个用户并没有 web
这个镜像仓库的访问权限,所以只能尝试推送到 zx19904227511
这个二级命名空间之下。因此需要使用 zx19904227511
这个ID,为当前镜像重新打一个标签。
- 为镜像重新打标签
1 | docker image tag web:latest zx19904227511/web:latest |
为镜像打标签的命令格式是 docker image tag <current-tag> <new-tag>
,其作用是为指定的镜像添加一个额外的标签,并且不需要覆盖已经存在的标签。
再次执行 docker image ls
命令,可以看到这个镜像现在有了两个标签,其中一个包含 Docker ID
zx19904227511
1 | $ docker image ls |
- 推送镜像到 Docker Hub
1 | $ docker image push zx19904227511/web:latest |
运行应用程序
下面的命令会基于 web:latest
这个镜像,启动一个名为 c1
的容器,该容器将内部的 8080
端口与 Docker 主机的 80
端口进行映射。这意味着可以在浏览器中输入 Docker 主机的 DNS 名称或者 IP 地址,然后就能直接访问这个 web 应用了。
注意,如果 Docker 主机已经运行了某个使用
80
端口的应用程序,可以在执行docker container run
命令的时候指定一个不同的映射端口。例如,可以使用-p 5000:8080
参数,将 Docker 内部应用程序的8080
端口映射到主机的5000
端口.
1 | docker container run -d --name c1 -p 5000:8080 web:latest |
-d
参数的作用是让应用程序以守护线程的方式在后台运行。-p 5000:8080
参数的作用是将主机的 5000
端口与容器内部的 8080
端口进行映射。
接下来验证一下程序是否真的成功运行,并且对外提供服务的端口是否正常工作
1 | $ docker container ls |
APP 测试
打开浏览器,在地址栏输入 DNS名称或者 IP地址加 5000
端口号,就能访问到运行的应用程序了。
如果没有出现正常的页面,尝试执行下面的检查来确认原因所在
- 使用
docker container ls
指令来确认容器已经启动并且正常运行。 - 确认防火墙或其他网络安全设置没有阻止访问 Docker 主机的
5000
端口。
Dockerfile 解析
- Dockerfile 中的注释行,都是以
#
开头的,除注释之外,每一行都是一条指令。指令的格式如下:INSTRUCTION argument
- 指令是不区分大小写的,但是通常都采用大写的方式。这样 Dockerfile 的可读性会高一点
docker image build
命令会按行来解析 Dockerfile 中的指令并顺序执行。- 部分指令会在镜像中创建新的镜像层,其他指令只会增加或修改镜像的元数据。
在上面的例子当中,新增镜像层的指令包括
FROM
,RUN
以及COPY
,而新增元数据的指令包括EXPOSE
,WORKDIR
,ENV
以及ENTERPOINT
。关于如何区分命令是否会新建镜像层,一个基本的原则是:如果指令的作用是向镜像中添加新的文件或程序,那么这条指令就会新建镜像层;如果只是告诉 Docker 如何完成构建或者如何运行应用程序,那么就只会增加镜像的元数据。
可以通过docker image history
来查看在构建镜像的过程中都执行了哪些命令
1 | $ docker image history web:latest |
在上面的输出内容当中,有两点是需要注意的。
首先,每行内容都对应了 Dockerfile 中的一条指令(顺序是自下而上)。CREATE BY
这一列中还展示了当前行具体对应 Dockerfile 中的哪条指令。
其次,从这个输出内容中,可以观察到只有4条指令会新建镜像层(就是那些 SIZE 列对应的数值不为零的指令),分别对应Dockerfile 中的 FROM
,RUN
以及 COPY
指令。
我们可以通过执行 docker image inspect
指令来确认确实只有 4 个层被创建了。
1 | <Snip> |
使用 FROM
指令引用官方基础镜像是一个很好的习惯,这是因为官方的镜像通常会遵循一些最佳实践,并且能帮助使用者规避一些已知的问题。除此之外,使用 FROM
的时候选择一个相对较小的镜像文件通常也能规避一些潜在的问题。
生产环境中的多阶段构建
对于 Docker 镜像来说,过大的体积并不好,越大则越慢,这意味着更难使用,而且可能更加脆弱,更容易遭受攻击。
鉴于此,Docker 镜像应该尽量小,对于生产环境来说,目标是将其缩小到仅包含运行的应用所必需的内容即可。问题在于,生成较小的镜像并非易事。
例如,不同的 Dockerfile 写法就会对镜像的大小产生显著影响。常见的例子是,每一个 RUN
指令会新增一个镜像层。因此,通过使用 &&
连接多个命令以及使用反斜杠(\
)换行的方法,将多个命令包含在一个 RUN
指令中,通常来说是一个值的提倡的方式。
另一个问题是开发者通常不会再构建完成后进行清理。当使用 RUN
执行一个命令时,可能会拉取一些构建工具,这些工具会留在镜像中移交至生产环境。这是不合适的。
有多种方式来改善这一问题,比如常见的是采用建造者模式。但无论采用哪种方式,通常都需要额外的培训,并且会增加构建的复杂度。
建造者模式需要至少两个 Dockerfile
,一个用于开发环境,一个用于生产环境。首先需要编写 Dockerfile.dev
,它基于一个大型基础镜像,拉取所需的构建工具,并构建应用。接下来,需要基于 Dockerfile.dev
构建一个镜像,并用这个镜像创建一个容器。这时再编写 Dockerfile.prod
,它基于一个较小的基础镜像开始构建,并从刚才创建的容器中将应用程序相关的部分复制过来。整个过程需要编写额外的脚本才能串联起来。这种方式是可行的,但是比较复杂。
多阶段构建(Multi-Stage Build)
是一种更好的方式!多阶段构建能够在不增加复杂度的情况下优化构建过程。
下面介绍一下多阶段构建方式
多阶段构建方式使用一个 Dockerfile
,其中包含多个 FROM
指令。每一个 FROM
指令都是一个新的构建阶段(Build Satge),并且可以方便地复制之前阶段的构建。
示例源码可以从 GitHub atsea-sample-shop-app 仓库下载,Dockerfile 位于 app 目录。这是一个基于 Linux 系统的应用,因此只能运行在 Linux 容器环境上。
- Dockerfile 如下所示:
1 | $ cat app/Dockerfile |
首先注意到,Dockerfile
中有 3 个 FROM
指令。每一个 FROM
指令构成一个单独的构建阶段。各个阶段在内部从 0 开始编号。不过,示例中针对每个阶段都定义了便于理解的名字
- 阶段 0 叫做
storefront
- 阶段 1 叫做
appserver
- 阶段 2 叫做
production
storefront
阶段拉取了大小超过 600MB 的 node:latest
镜像,然后设置了工作目录,复制一些应用代码进去,然后使用 2 个 RUN
指令来执行 npm
操作。这会生成 3个镜像层并显著增加镜像大小。指令执行结束后会得到一个比原镜像大得多的镜像,其中包含许多构建工具和少量应用程序代码。appserver
阶段拉取了大小超过 700MB 的 maven:latest
镜像。然后通过2个 COPY
指令和 2个 RUN
指令生成了 4个镜像层。这个阶段同样会构建出一个非常大的包含许多构建工具和非常少量应用程序代码的镜像。production
阶段拉取 java:8-jdk-alpine
镜像,这个镜像大约 150 MB,明显小于前两个构建阶段用到的 node
和 ,maven
镜像。这个阶段会创建一个用户,设置工作目录,从 storefront
阶段生成的镜像中复制一些应用代码过来。之后设置一个不同的工作目录,然后从 appserver
阶段生成的镜像中复制应用相关的代码。最后,production
设置当前应用程序为容器启动时的主程序。
重点在于 COPY --from
指令,它从之前的阶段构造的镜像中仅复制生产环境相关的应用代码,而不会复制生产环境不需要的构件。还有一点也很重要,多阶段构建这种方式仅用到了一个 Dockerfile
,并且 docker image build
命令不需要再增加额外的参数。
下面演示一下构建操作。clone
代码并切换到 app
目录,并确保其中有 Dockerfile
。
1 | # git clone https://github.com/dockersamples/atsea-sample-shop-app |
执行 docker image ls
命令查看由构建命令拉取和生成的镜像
docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
node latest bd4dba13afd5 5 days ago 936MB
maven latest cb84ab7a9ef8 4 weeks ago 747MB
java 8-jdk-alpine 3fd9dd82815c 3 years ago 145MB
multi stage b9860c6c5421 5 minutes ago 159MB
``
输出内容的第1行显示了在 `storefront` 阶段拉取的 `node:latest` 镜像,下一行内容为该阶段生成的镜像(通过添加代码,执行 npm 安装和构建操作生成该镜像)。这两个都包含了许多的构建工具,因此镜像体积非常大。
第2行是在 `appserver` 阶段拉取和生成的镜像,它们也都因为包含许多构建工具而导致体积较大。
最后一行是 `Dockerfile` 中的最后一个构建阶段生成的 `multi:stage` 镜像。可见它明显比之前阶段拉取和生成的镜像要小。这是因为该镜像是基于相对精简的 `java:8-jdk-alpine` 镜像构建的,并且仅添加了用于生产环境的应用程序文件。
最终无须额外的脚本,仅对一个单独的 `Dockerfile` 执行 `docker image build` 命令,就创建了一个精简的生产环境镜像。