每位开发者都经历过软件不兼容之痛。当我们需要同时开发几个使用不同 Java 运行时版本的项目时,这些问题会急剧爆发,特别是在 OsX 平台上。为此,Ruby 使用自己的版本管理工具。我的两个同事曾用了几小时来调试他们各自用 Homebrew 管理的 OpenSSL 和 Python 版本之间的不兼容。我们是否可以使用容器来解决这些问题呢?答案是肯定的!
容器的主要目标是交付软件。新成立的开放容器项目给出以下定义:
标准容器的目标是使用自描述和可移植的格式,封装软件组件及其全部依赖,以便任何兼容运行时都可以运行,无需额外的依赖,不必关心底层机器和容器的内容。
这份定义没有提及任何关于软件分发类型的描述。这是有意而为之的,因为容器的设计是内容无关的。我们要交付什么以及如何使用完全取决于我们自己。在这篇文章中,我将阐述服务镜像和可执行镜像之间的区别,并建议读者使用可执行镜像。
可执行镜像没有服务镜像那么常见,但却是一个非常有用的补充。可执行镜像要解决的是软件兼容性等问题。我们拿官方的Maven 镜像作为例子,探索可执行镜像是什么、它们是如何工作的,以及我们如何创建可执行镜像。其中,Dockerfile 中的ENTRYPOINT 指令是演绎可执行镜像的核心角色。
1 服务镜像 VS. 可执行镜像
传统上,容器镜像被用作长时间运行的进程:在服务器上运行的服务,不会影响主机,因为它们存在与容器内。我们称其为服务镜像。Web 服务器、负载均衡服务器和数据库服务器都是服务镜像的好例子。这类容器可以很容易与虚拟机对比。
容器镜像也可以用作短暂的进程:在我们计算机上运行的、容器化的可执行命令。这些容器执行单一的任务,生命周期短暂,而且通常可以在使用后被删除。我们称之为可执行镜像。举例来说,比如编译器(Golang)或者构建工具(Maven)、演示软件(我很喜欢用 Markdown 格式写一个演示,然后用 RevealJS Docker 镜像将其展示出来),以及浏览器。可执行镜像的终极布道者是 Docker 公司的 Jessie Frazelle 。如果你希望获得更多启发,一定要阅读她博客中相关的内容,或者看下她在 DockerCon 2015 上的演讲。
其实,服务镜像和可执行镜像之间的界限并非泾渭分明。镜像都是可执行的,因为它们的任务就是运行一个进程。在容器中运行一个演示或者浏览器是非常好的本地工具示例,因此我将称其为可执行镜像。纵然他们是长时间运行的进程。话虽如此,我希望读者能够认同这样分类的道理。
如此定义的出发点,更多是从镜像的目的,而不是进程存活的长短。
2 可执行镜像的优势
那么,可执行镜像的优势是什么呢?它们是如何解决前述问题的呢?
其中一个原因是,对可执行镜像的体验是一种很好的开始使用 Docker 的方式。这种体验非常有用,而且不会影响生产环境。此中的趣味无穷!
另一个原因是安装方便。众所周知的包管理器 apt-get、yum、MacPorts 和 homebrew 等,通常在大部分时间有完美的表现,但是当我们真的需要它们的时候……问题在于,这些工具的伟大之处是同一件事情:管理依赖。但是,它们没有强大到可以管理同一个包的两个版本,包括其依赖关系树。容器的设计没有依赖性:所有的依赖都被固化到镜像中。安装本身只意味着运行 Docker、执行命令。如果镜像不存在于系统中,Docker 会自动下载(pull)该镜像。通过将软件与其依赖一起封装在容器镜像中的方式,实现了可靠的软件分发。测试容器镜像即是测试依赖是否能与主要功能一起工作。
容器化的可执行文件仅指容器化,换个说法叫沙箱。这降低了运行不完全信任软件的风险,避免了许多程序的漏洞。一个例子是浏览器中的可疑链接。在一个干净的文件系统中运行一个全新的浏览器会更安全。另一个例子是关于几个月前 Valve 软件的 Steam 删除了所有用户的文件,包括连接的驱动器的缺陷!Docker 的沙箱机制并非完美,但它肯定会避免发生清除照片库这样的事情。
因为进程及其依赖是封装在容器中的,运行同一软件的不同版本变得非常简单!通常情况下,要开始一个Java/Maven 项目,我们需要安装所需版本的Java 开发套件(JDK)和Maven。而使用Docker,我们就可以跳过这步。 JDK 和Maven 由某个团队安装在一个可执行镜像中。于是,其他人就可以在此基础上迁出源代码,并直接编译和测试它们。我们可以为另一个使用不同JDK 版本的项目使用另一个镜像。甚至可以在同一时间编译这些项目!而不需要担心$JAVA_HOME 环境变量。
3 Maven 镜像
构建服务镜像的目的是以指定的方式运行一个服务。这也许需要一些环境相关的信息,比如数据库地址,但不会很多。构建可执行镜像的目的是运行一个以指定方式与系统交互的工具。有很多技术可以实现这一目的。我们将以 Maven 编译器镜像作为这一技术的实现示例。需要注意的是,这里所指的技术是通用的,所以纵然你不喜欢 Java,请稍安勿躁。
4 使用卷传递文件
假设我们有一个包含 Java 源代码的 Maven 项目,该项目至少在根目录下,包含一个 pom.xml 文件和 /src/main/java 目录。对于本文而言,可以采用任何你想用的 Maven 项目。如果你没有任何 Maven 项目,你可以去下载 Spring Boot(选择 Maven 类型)。使用命令行 cd 到项目目录(包含 pom.xml 文件的目录),执行如下命令:
user:project$ docker run --rm \ -v $(pwd):/project \ -w /project \ maven:3.3.3-jdk-8 mvn install
该命令做了如下的事情:
docker run
创建了 maven:3.3.3-jdk-8 镜像的一个实例。该实例中执行了mvn install
命令。原则上,这不会影响主机系统。-v $(pwd):/project
将当前目录挂载到容器中,作为 /project 目录。这样以来,容器就可以读写主机系统的当前目录了。-w /project
设置了 /project 作为工作目录。这意味着执行 mvn 命令将在 project 目录中有效。--rm
将在执行完毕后删除容器。甩掉包袱!
这与在主机上直接运行 mvn install 的结果是一样的,只是不必实际安装 Java 或 Maven。我们以在项目目录下,获得 target 目录而告终,该目录包含了编译好的 Java 应用程序。
可以运行 maven clean 命令清理项目:
user:project$ docker run --rm \ -v $(pwd):/project \ -w /project \ maven:3.3.3-jdk-8 mvn clean
5 使用 entry point 传递参数
Maven 镜像的功能是运行 mvn [args]。因此,我们可以认为在 Docker 命令中指定 mvn 是多余的。为此,可以使用 Docker 提供的 entrypoint。这个 entrypoint 是与命令强关联的。可以在 Dockerfile 中分别使用 ENTRYPOINT 和 CMD 指令。这两个指令将作为容器镜像的元数据,覆盖docker run
命令。我们可以这样执行mvn clean install
:
user:project$ docker run --rm \ -v $(pwd):/project \ -w /project \ --entrypoint mvn \ maven:3.3.3-jdk-8 clean install
entrypoint 和命令将连接在一起执行。它的优点是关注点分离。对于可执行容器镜像而言,entrypoint 可以用作定义恒定部分,命令可以用作定义可变部分。
如果我们将 entrypoint 融入容器镜像,分离会更加优雅。为此,我们在另一目录中创建一个 Dockerfile 文件,内容如下:
FROM maven:3.3.3-jdk-8 WORKDIR /project ENTRYPOINT ["mvn"] CMD ["-h"]
其中,我们同样增加了一个工作目录,因此我们的新镜像希望 Maven 项目挂载在 /project 目录之下。Dockerfile 以 exec 的形式定义了 ENTRYPOINT 和 CMD,方括号内的参数最终被解析为 shell。在 Dockerfile 文件所在的目录下,执行docker build -t my_mvn .
命令构建镜像,这个镜像简化了前述的执行命令:
user:project$ docker run --rm \ -v $(pwd):/project \ my_mvn clean install
其中,clean install
当然可以替换为 mvn 的其他参数。如果我们忘记包含命令参数,将会打印maven help
,因为在 Dockerfile 文件中定义了默认的命令参数,-h
即表示 help。
entrypoint 的另一个很好的用途是在方括号内定义辅助脚本。例如,如果在实际服务正常启动之前,我们需要执行一些命令,辅助脚本可以很好地处理。另外,这样的脚本还可以检查当前是否具备了必要的全部运行时配置,比如链接或环境变量等。命令本身作为启动脚本的参数,但是对执行脚本是透明的。关于这一点的更多信息以及简单示例,请参阅 Docker 文档中的 Dockerfile 最佳实践。
6 为可执行镜像创建别名
我们可以为可执行镜像创建一个别名。这样,我们就可以输入简短的指令,就像普通程序一样。在~/.profile 中添加:
mvn() { docker run --rm \ -v $(pwd):/project \ my_mvn $* }
因为我们要传递参数,所以使用函数代替了别名。在执行source ~/.profile
命令,加载变更后,我们就可以这样简单地使用了:
user:project$ mvn clean install
7 使用卷缓存 Maven 本地仓库
当前方案的缺点是,每次执行时都需要下载 Maven 工件。本地 Maven 安装总会包含一个仓库目录,其中存储了所有的 Maven 工件。目前的方法是很简洁,但是并不实用。让我们将 Maven 仓库作为卷添加进来。创建一个目录,比如/usr/tmp/.m2
,然后运行:
user:project$ docker run --rm \ -v $(pwd):/project \ -v /usr/tmp/.m2:/root/.m2 \ my_mvn install
现在,主机上的/usr/tmp/.m2
目录中存储了 Maven 下载下来的工件。我们以后每次用这种方式启动 Maven 容器镜像,因为引入了这个目录,所以 Maven 会重用那些工件。可以重复执行mvn install
两次来检验不同。
我们只是让 Maven 构建更快了。但是,为此,我们不得不在主机上管理一个目录。在本文的最后一步中,我们将使用 Docker 管理这个卷。首先,我们创建一个叫 data 的容器:
user:project$ docker run --name maven_data \ -v /root/.m2 \ maven:3.3.3-jdk-8 echo 'data for maven'
容器创建完毕会打印“data for maven”,该容器创建了一个卷。这里使用什么镜像不是核心问题,在本例中使用 maven:3.3.3-jdk-8 是方便,因为它已经下载到主机了,而使用 my_mvn 不太方便,因为 entrypoint 要预先考虑 echo 声明。注意,这里没有-v /root/.m2:
中的冒号,因为我们不再引入主机目录。而是让 Docker 在主机上创建自己的数据目录。使用“data”作为名字并非是必需的命令,但是这样是为了显式说明这是一个数据容器, 当执行docker ps
时,该名称将会反射显示。我们可以通过--volumes-from
使用这个容器的卷,而无需考虑 Docker 持有的实际目录。这样做会引入容器中的 /root/.m2 作为挂载卷。这种技术对共享容器之间的数据也非常有用。我们修改~/.profile 如下:
mvn() { docker run --rm \ -v $(pwd):/project \ --volumes-from maven_data \ my_mvn $* }
现在,当我们运行 mvn 时,Maven 主目录将映射到这个卷。Maven 容器自身会被删除,但是卷会在缓存的本地仓库中保留。如果我们希望清理系统,可以使用如下命令删除数据容器:
docker rm -v maven_data
-v
表示与之相关的容器满足如下条件时,删除该卷:
- 卷是由 Docker 管理的
- 没有其他容器引用
一个忠告:如果你忘记了使用-v
选项,最终会产生孤儿卷目录。
8 总结
可执行容器镜像是一种强大的 Docker 应用程序。对于软件分发,以及以限制和验证的方式在计算机上运行时,非常有用。此外,这是一种有趣的开始 Docker 体验的方式。我希望你能通过此文,在开始尝试 Docker 和使用相关技术上,得到了启发。
关于作者
Quinten Krijger在开始他的 IT 职业生涯前,曾经研究过物理学和一年的古典唱法。后来,他搬到阿姆斯特丹的 Trifork,主要的工作是继续使用开源技术,比如 Java、Spring、ElasticSearch 和 MongoDB,完成项目后端的工作,对最新的前端设计颇有感觉。他热衷于缩短反馈周期和启用敏捷开发:测试、CI 和 DevOps 是此中的关键词。在 Docker 出现后不久,他便产生了兴趣,并深刻地认识到,有效的容器可以提供非常广泛的可能性。他致力于启动容器解决方案上已经有半年了,目前是 ING 的一名 DevOps。
查看英文原文: Executable Images - How to Dockerize Your Development Machine
评论