每位开发者都经历过软件不兼容之痛。当我们需要同时开发几个使用不同Java运行时版本的项目时,这些问题会急剧爆发,特别是在OsX平台上。为此,Ruby使用自己的版本管理工具。我的两个同事曾用了几小时来调试他们各自用Homebrew管理的OpenSSL和Python版本之间的不兼容。我们是否可以使用容器来解决这些问题呢?答案是肯定的!
容器的主要目标是交付软件。新成立的开放容器项目给出以下定义:
标准容器的目标是使用自描述和可移植的格式,封装软件组件及其全部依赖,以便任何兼容 运行时都可以运行,无需额外的依赖,不必关心底层机器和容器的内容。
每位开发者都经历过软件不兼容之痛。当我们需要同时开发几个使用不同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和使用相关技术上,得到了启发。