ThinkChat2.0新版上线,更智能更精彩,支持会话、画图、视频、阅读、搜索等,送10W Token,即刻开启你的AI之旅 广告
# 第7章 构建镜像 镜像是所有容器的运行之本,因此,在构建Docker基础设施时,掌控构建镜像的艺术非常必要。 构建镜像的方式将决定容器部署的速度、从容器获取日志的难易程度、可配置的多少以及其安全程度。尽管构建镜像时的首要关注点是容器可按预期运行,但在生产环境中,这里所列举的因素将变得非常重要。 在着手构建镜像之前,我们需要理解其实现方式的几个方面。 虽然表面上Docker镜像与虚拟机镜像并无太大差异,但它们的实现方式却完全不同。虚拟机镜像提供了完整的文件系统虚拟化:镜像中的文件系统可以与镜像所在宿主机的文件系统完全不同。虚拟机镜像通常以数据卷的形式实现,以大文件形式存储在宿主机操作系统中。一旦为虚拟机分配了数据卷,虚拟机里的访客操作系统就会在这个卷上创建并格式化出一个或多个分区。虚拟机管理程序会将这些文件作为原始磁盘展现给访客操作系统。 这种虚拟化文件系统的方法提供了巨大的隔离性和灵活性,但其效率可能不佳。例如,在使用同一个镜像运行多个虚拟机,或需要来自同一基础镜像的多个不同镜像时,效率就会比较低。克隆虚拟机的标准方法是为新镜像创建一份文件系统的新副本,使得两个镜像里的文件系统可以独立演进。这种方法在创建副本时的磁盘空间占用和时间花费上代价昂贵,也正因为这样,虚拟机厂商在这些普通复制无法正常工作的场景中,依靠**写时复制**(copy-on-write,CoW)技术来提高镜像的使用效率。 在创建和运行多个从同一基线数据启动的进程时,写时复制技术可以节省大量时间和空间。对于虚拟化而言,假设有20台虚拟机需要使用相同的基础镜像,使用写时复制的话,用户就不需要创建20份该镜像的副本(每个虚拟机一份)。相反,所有虚拟机可以从相同的镜像文件启动,带来更快的启动速度,并节省运行所有虚拟机需要的大量磁盘空间。写时复制会让每个虚拟机里的访客操作系统以为它们是在基础镜像中对文件系统进行独立修改,它的实现方式是为每个虚拟机提供一个在共享基础镜像之上的叠加层,这个层可以独立于其他虚拟机进行修改。每当操作系统尝试对文件系统进行修改时,实际上是发生在这个叠加层上的,基础镜像保持原封不动。 在操作系统想要对文件系统进行修改时,处于这些场景之后的虚拟机会把这些被编辑的磁盘扇区复制到叠加层上,并将这些副本提供给访客操作系统,让其当作原始版本。然后,虚拟机管理程序就允许访客操作系统修改叠加层上的副本,保持基础镜像中的原始扇区不变。从这刻开始,这个虚拟机就再也看不到这些扇区的原始共享副本,只能看到叠加层中的副本(如图7-1所示)。虚拟机管理程序为访客操作系统提供了一个“幻象”:作为叠加层与基础镜像合并结果的文件系统会被当作一个单一的数据卷。 ![图像说明文字](https://box.kancloud.cn/99ccff0a37fc3e46a1e9b6c0f8f3da6a_700x736.jpeg) 图7-1 Docker镜像生来就是基于写时复制技术的,且与标准的虚拟机不同,Docker的镜像并不是完全虚拟化的,它们是构建于宿主机的文件系统之上的。这种方法是否比完全虚拟化具有性能优势还有待考证,而且很大程度上取决于具体用例。例如,虚拟机世界里的写时复制通常是基于扇区的,也就是,只有基础镜像上有变动的文件磁盘扇区会被复制到叠加层上进行编辑,而对Docker而言,整个文件会被复制和编辑,因此即便只是大文件的一部分被修改,整个文件都需要被复制。另一方面,使用Docker的镜像,访客和宿主机操作系统之间不需要文件系统转换。我们讨论构建Docker镜像时重要的一点是,Docker进一步发挥了写时复制技术的作用,可以很容易地堆叠多个写时复制叠加层以创建一个镜像或一系列相关的镜像。 Docker使用写时复制的主要原因有二。其一是让用户可以交互地构建镜像,一次添加一个层。其二则具有更深远的意义,与镜像的存储和分发有关。在我们构建系统时,我们通常采取的方式是,所有服务都基于相同的操作系统的小集合,甚至是共享一些基础配置。以这种方式设置的容器镜像彼此的差异只在于配置的“最后一英里”,这最后一英里只包含了将该镜像与其他镜像区分开来的内容,有些时候,容器使用的是完全一样的镜像。在这些场景中,写时复制可以非常有效地节省时间和空间。 Docker还使用写时复制将容器运行于一个叠加层上,而非直接运行于镜像上。原始镜像是以只读模式被使用的,容器可能对文件系统做的任何修改都只会在这个叠加层上执行。读者可能阅读过“Docker镜像是不可变的”这样的Docker文献,其确切意思是:一旦镜像被创建,它就无法再被修改,因此你能做的事是在它的基础上构建新镜像。 Docker叠加层的使用其真正强大之处在于这些叠加层可以跨宿主机进行共享。每个叠加层包含了对其基础镜像的引用,而后者又是另外一个叠加层。每个叠加层都拥有唯一的ID,以及一个可选的名称和版本号。Docker镜像的具名叠加层是在镜像仓库中存储与共享的。部署一个容器时,Docker会检查容器所需的镜像是否在本地仓库中已经存在。如果在本地不存在,Docker在镜像仓库检索这个镜像,并拉取该镜像所有叠加层的引用,然后确定哪些层已经在本地存在,并下载那些缺失的叠加层。 这种方法可以减少保存宿主机中所有镜像所需的空间,并能显著地减少新镜像的下载时间。例如,在一个运行10个容器的场景中,10个镜像的主干都来自于相同的基础CentOS 7镜像,宿主机只需要下载一次基础镜像,以及10个不同的叠加层,无须下载10个都包含CentOS 7完整副本的镜像。同样,下载更新的镜像只需要下载最新的几个叠加层。 本章后面将详细讨论如何再利用这些特性,但我们先来看一下构建镜像的主要方面:使其工作。 从最基本的层面讲,构建一个容器镜像(后面简称为镜像)可以通过两种方法完成。第一个方法是从一个基础镜像(`ubuntu-14.04`)启动一个容器,在容器内运行一系列命令,如安装软件包、编辑配置文件等,一旦镜像处于期望状态,对其进行保存。 我们来看看它是如何工作的。在一个终端中,使用`ubuntu`的基础镜像启动一个运行`/bin/bash`可交互的容器。一旦进入容器内的shell,我们就在根目录中创建一个名为`docker-was-here`的文件。这项操作不应修改基础镜像。相反,新文件应被创建在容器的文件系统叠加层上: ``` $ docker run -ti ubuntu /bin/bash root@4621ac608b25:/# pwd / root@4621ac608b25:/# ls bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var root@4621ac608b25:/# touch docker-was-here root@4621ac608b25:/# ``` 现在,我们在第二个终端中创建一个基于上述容器内容的新镜像,上述示例中其ID是`4621ac608b25`。 ``` $ docker commit 4621ac608b25 my-new-image 6aeffe57ec698e0e5d618bd7b8202adad5c6a826694b26cb95448dda788d4ed8 ``` 最后,我们在这个终端中启动一个新容器,这一次使用的是我们新创建的`my-new-image`镜像。我们可以验证镜像包含了我们自建的`docker-was-here`文件。 ``` $ docker run -ti my-new-image /bin/bash root@50d33db925e4:/# ls bin boot dev docker-was-here etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var root@50d33db925e4:/# ls -la total 72 drwxr-xr-x 32 root root 4096 May 1 03:33 . drwxr-xr-x 32 root root 4096 May 1 03:33 .. -rwxr-xr-x 1 root root 0 May 1 03:33 .dockerenv -rwxr-xr-x 1 root root 0 May 1 03:33 .dockerinit drwxr-xr-x 2 root root 4096 Mar 20 05:22 bin drwxr-xr-x 2 root root 4096 Apr 10 2014 boot drwxr-xr-x 5 root root 380 May 1 03:33 dev -rw-r--r-- 1 root root 0 May 1 03:31 docker-was-here drwxr-xr-x 64 root root 4096 May 1 03:33 etc .... drwxr-xr-x 12 root root 4096 Apr 21 22:18 var root@50d33db925e4:/# ``` 尽管这种构建镜像的交互方法非常直观,但无助于可重现和自动化。对生产环境设置而言,很有必要使用可轻松重现的方法进行镜像自动化构建。Docker提供了一个方法来完成这件事,该方法基于一个名为`Dockerfile`的文件。 一个`Dockerfile`包含了一系列指令,Docker在一个容器中运行这些指令以产生一个镜像。这些指令可以分为两组:一组修改镜像的文件系统,一组修改镜像的元数据。修改文件系统的指令示例之一是`ADD`——将URL定义的远程地址文件写入到镜像文件系统中,或`RUN`——在镜像上运行一个命令。修改元数据的指令示例之一是`CMD`——设置了容器进程启动时要运行的默认命令及其参数。 在使用`docker build`时,Docker会以Dockerfile的`FROM`指令指定的基础镜像来启动一个临时的容器,然后在这个容器的上下文中运行每条指令。Docker会为每条指令创建一个中间镜像。这是为了方便用户渐进地构建镜像:在Dockerfile里修改或添加一条指令时,Docker知道在此之前的指令并未发生变化,所以它会使用运行完上一条指令后构建的镜像。 例如,要使用`Dockerfile`构建出和此前以交互方式构建而来的相同的镜像,首先创建一个新目录,然后在该目录中创建一个名为Dockerfile的文件,其内容为: ``` FROM ubuntu MAINTAINER Me Myself and I RUN touch /docker-was-here ``` 接下来,我们告诉Docker使用这个Dockerfile来构建`my-new-image`镜像: `$ docker build -t my-new-image .`Docker默认会在当前目录中查找`Dockerfile`。如果用户使用的是其他文件名或Dockerfile位于其他位置,可以使用`-f`来告诉Docker所使用的Dockerfile路径: `$ docker build -t my-new-image -f my-other-dockerfile .`@#@#@#@#@nav_point_90 正如前面所说,Docker镜像呈现出的是一个分层构架,镜像由一堆的文件系统叠加层组成。每一层都是源自前一层的一组文件的增加、修改和删除。在层里增加文件时,新文件将被创建。在层里删除文件时,这个文件会被标记为已删除,但是请注意,这个文件还包含在前面的层里。在层里修改文件时,取决于Docker所运行的存储驱动程序(在后面的存储章节中详述),要么整个文件在新层里被重新创建,或者只有这个文件的部分磁盘扇区在新层里被新扇区所替换。不论哪种方式,旧文件在前面的层中保持不变,而新层则包含了其新的修改。在每个层都有一个镜像,这个镜像是在基础镜像之上依次叠加先前层的结果。在所得到的镜像之上同样也是前面所有层的结果。 在构建Docker镜像时,通常会从一个现有基础镜像开始,这个镜像可能已经包含了很多层。Docker会按顺序运行`Dockerfile`里的每条指令,在每条指令结束时Docker会以运行该指令产生的文件系统的变化生成一个新层。这是一项非常好的功能,因为它允许渐进式开发镜像,而不必每次都等待所有的指令运行。例如,假设因为第10条指令包含一个错误,导致Docker构建镜像时失败,然后当用户在修复了这个失败的指令后重新尝试构建时,Docker将不需要再次运行前面的9条指令。相反,它将以上一次正确构建的层为起点,然后从之前的失败指令开始继续构建。这可大大节省时间,因为有些指令可能会运行一些比较耗时的命令。 这种分层架构在部署阶段同样具有优势,因为在部署一个新镜像时,某些较深的层可能已经存在于宿主机中,因此只有新层需要通过网络发送过去。在运行基于同一个或类似镜像的多个容器时,这项功能将极大地减少所需的时间和空间(如图7-2所示)。 ![图像说明文字](https://box.kancloud.cn/b835bc70dbc8685d463773063a6bdafb_700x690.jpeg) 图7-2 这种分层架构也带来了一些在现实场景中需要考虑的注意事项。其中之一是,镜像无法缩小。如果一个镜像所有层加起来在文件系统中是500 MB,扩展它的任何自定义镜像在文件系统中至少需要500 MB,即使上层删除了下层的文件。 镜像大小相当重要,特别是在宿主机上安装这些镜像时,不仅因为下载镜像到宿主机所耗费的时间取决于它的大小,还因为镜像文件越大宿主机上所需的空间也越多。在开发过程中,其大小也很重要,因为对于一个新的开发人员,或对于启用一组新的Docker宿主机的人,甚至对持续集成/持续部署的服务器来说,都有可能需要花费大量时间下载开发过程所需容器的所有镜像。考虑到Docker是用来提升开发过程,这多少会让人有些沮丧。 注意,如果正在部署一个微服务架构,减小镜像大小尤为重要。但是,如果正在部署大型的虚拟机类容器,由于你可能使用了一个功能完整的操作系统,本节的大部分内容并不会给你带来多大作用。 #### 从“小”做起 控制镜像空间要从最小化的基础镜像开始。在极端情况下,可以从一个空的文件系统开始,并在其中部署操作系统,不过这应该不是应用最广的方法。 下一个选项是类似[busybox](http://www.busybox.net/)或[alpine](http://www.alpinelinux.org/)的微型发行版。Busybox大约2.5 MB,一开始是为嵌入式应用程序创建的。它包含了最基本的Unix工具供用户使用,不过用户也可以创建添加(或删除)了额外命令的自己的busybox发行版。Busybox对支持运行静态编译二进制文件的镜像支持良好,如用Go写的进程。 Alpine在Busybox基础上,扩展添加了一个以安全为重点的内核构建版本及一个名为`apk`的包管理器,并是基于[Musl libc](http://www.musl-libc.org/)——一个更轻也可能更快的libc版本。Alpine可以作为容器通用的Linux发行版,不过用户将不得不花费更多的力气完成所需功能的配置:尽管Alpine提供了一个包管理器,可用的包列表比Debian或CentOS这类完整的发行版要小得多。相比全功能发行版,Alpine的优势在于它活动部件更少,因此更小巧,且更易于理解,而且作为一个必然结果,也更易于加固。 下一个选项是使用主流Linux发行版版本(如Ubuntu或CentOS)的容器优化版本。这些容器通常运行完整发行版的精简版本,它们删除了所有的桌面程序,且具有针对生产环境服务器优化的配置。这些镜像一般只有几百MB,如Ubuntu 14.04大约是190 MB,而CentOS 7大约是215 MB,不过它们提供了一个全功能的操作系统。这是目前在构建自定义镜像时最容易入手的地方,因为它们对打包服务的支持相当好,而且网络上存在大量的文档和手册可以作参考。Docker registry充满了这类镜像,大多数镜像由Docker公司直接支持。 一个明智的选择是,标准化出一个特定的镜像版本,并尽可能地将其用于所有容器。如果宿主机里所有的容器都具有相同的基础镜像,一旦第一个容器下载完成,下载其余容器的速度将更快,因为它们无须重新下载基础镜像层。不过需要注意的是,只有基础镜像已经下载好**之后**,才能在下载其他镜像时发挥作用。截至本文撰写时,如果在基础镜像尚未下载完成时,同时下载多个镜像,那么每个镜像都会下载一次基础镜像,因为在开始下载镜像时本地仓库还不存在这个基础镜像。 在选定一个小的基础镜像之后,下一步是在运行Dockerfile之后保持镜像的小巧。 每运行Dockerfile里的一个命令,就会生成一个新的镜像层。层生成时,一个新的最小镜像大小就被设定了:即使用户在Dockerfile的下一个命令中删除文件,也不会释放任何空间,位于宿主机文件系统上的镜像大小也不会缩小。 出于这个原因,如何在Dockerfile中组织命令将影响最终的镜像大小。例如,通过包管理器安装一个软件包:当调用包管理器时,它的索引会被更新,它会下载一些包到缓存目录,并在将包中文件放置到文件系统的最终位置前,将包展开到预演区域。如果用户像往常一样运行包安装命令,这些永远也用不上的缓存包文件将会永远地成为镜像的一部分。不过,如果用户在同一条安装命令中删除它们,这些文件就会像从未存在过一样。 例如,可以像这样在一个单一步骤里安装Scala并执行清理操作: ``` RUN curl -o /tmp/scala-2.10.2.tgz http://www.scala-lang.org/ files/archive/scala-2.10.2.tgz \ && tar xzf /tmp/scala-2.10.2.tgz -C /usr/share/ \ && ln -s /usr/share/scala-2.10.2 /usr/share/scala \ && for i in scala scalc fsc scaladoc scalap; do ln -s /usr/ share/scala/bin/${i} /usr/bin/${i}; done \ && rm -f /tmp/scala-2.10.2.tgz ``` 在上面的示例中,如果像这样独立地运行上述命令: ``` RUN curl -o /tmp/scala-2.10.2.tgz http://www.scala-lang.org/ files/archive/scala-2.10.2.tgz RUN tar xzf /tmp/scala-2.10.2.tgz -C /usr/share/ RUN ln -s /usr/share/scala-2.10.2 /usr/share/scala RUN for i in scala scalc fsc scaladoc scalap; do ln -s /usr/ share/scala/bin/${i} /usr/bin/${i}; done RUN rm -f /tmp/scala-2.10.2.tgz ``` 其后的镜像将包含这个`.tgz`文件,尽管在最后一条命令之后无法在文件系统中看到这个文件。 有两种可以配置容器中运行进程的方法:一种方法是通过环境变量将配置传递给容器内部,另一种是将配置文件和/或目录挂载到容器中。两种方法都发生在容器启动时期。两种方法都是非常有用,有各自的应用场所,但它们本质上有很大的不同。 #### 通过环境变量配置 在Docker启动一个容器时,它可以将环境变量转发给容器进程,进而转发给运行于容器内的进程。我们来看一下它是如何工作的。启动一个容器,在shell中运行一个命令来打印环境变量`MY_VAR`的值: ``` $ docker run --rm busybox /bin/sh -c 'echo "my variable is $MY_VAR"' my variable is ``` 这个环境变量未在容器中预定义,因此并没有值。现在在容器内运行相同的命令,不过这次我们通过Docker传递一个环境变量给容器: ``` $ docker run -e "MY_VAR=docker-was-here" --rm busybox /bin/sh -c 'echo "my variable is $MY_VAR"' my variable is docker-was-here ``` 理想情况下,通过环境变量,容器内的进程是完全可配置的。有时,我们会容器化那些通过配置文件获取配置的服务。我们将在下一节中讨论如何处理这些场景,不过现在我们先专注于环境变量的直接使用。 使用环境变量可以在进程及其配置间提供大量的隔离,这在“十二要素”([12 factor](http://12factor.net/config),一份用于构建基于服务的应用程序的宣言)中被认为是更好的方法。Docker为此设计了一个参数选项,在启动时将这些环境变量传递给容器。 这种分离的好处在于用户可以使用相同的镜像,而不管如何计算用于运行容器的配置。当容器化的进程通过环境获取它的配置时,所有的配置责任都属于调用Docker来启动容器的那个进程。这个模式带来了极大的灵活性,因为配置可以来自容器启动脚本的硬编码中,或来自文件,或来自一些分布式配置服务,甚至是来自于调度器。 有时,用户需要包装一个无法通过环境变量配置的服务。最常见的场景是从一个或多个配置文件(如`nginx`)读取配置的进程。 #### 1. 使用模板文件 有一个应用广泛的模式用于处理这种场景:使用一个入口点脚本,获取环境变量并在文件系统上生成配置文件,然后调用实际进程,该进程将在启动时读取那些新生成的配置文件。 我们来看一个示例。构建一个容器使用[node-pushserver](https://www.npmjs.com/package/node-pushserver)来给iOS和Android手机发送推送通知。对于这个例子,我们会创建一个名为`entrypoint.sh`的shell脚本,并在`Dockerfile`中将其添加到容器中: ``` from node:0.10 RUN npm install node-pushserver -g \ && npm install debug –g ADD entrypoint.sh /entrypoint.sh ADD config.json.template /config.json.template ADD cert-dev.pem /cert-dev.pem ADD key-dev.pem /key-dev.pem ENV APP_PORT 8000 ENV CERT_PATH /cert-dev.pem ENV KEY_PATH /key-dev.pem ENV GATEWAY_ADDRESS gateway.push.apple.com ENV FEEDBACK_ADDRESS feedback.push.apple.com CMD ["/entrypoint.sh"] ``` 这个`Dockerfile`具有多个环境变量默认值。如上所见,这些环境变量决定了从服务自身端口到MongoDB的主机/端口,以及所需证书的位置,甚至是所使用的苹果服务器(在开发环境和生产环境中,它们可能会有所不同)。最后,容器将运行的进程是我们自己的`entrypoint.sh`,它看起来像是这样的: ``` #!/bin/sh # 渲染一个模板配置文件 # 展开变量 + 保留格式 render_template() { eval "echo \"$(cat $1)\"" } ## 如果没有MongoDB前缀,则拒绝启动 [ -z "MONGODB_CONNECT_URL" ] && echo "ERROR: you need to specify MONGODB_CONNECT_URL" && exit -1 ## 对引号进行转义,以免在渲染时被删除 cat /config.json.template | sed s/\"/\\\\\"/g > /config.json.escaped ## 渲染模板 render_template /config.json.escaped > /config.json cat /config.json /usr/local/bin/pushserver -c /config.json ``` 这个脚本文件有些需要注意的地方。首先,我们定义了一个函数`render_template`,参数是一个文件名,它将展开其中的环境变量,并返回其内容。 接着,我们对因为某些关键配置不存在就很快失败的情况做了严格把关。在这里,我们要求调用者提供一个名为`MONGODB_CONNECT_URL`的环境变量,它没有默认值。 最后是从模板生成配置文件的部分。模板看起来是这样的: ``` { "webPort": ${APP_PORT}, "mongodbUrl": "${MONGODB_CONNECT_URL}", "apn": { "connection": { "gateway": "${GATEWAY_ADDRESS}", "cert": "${CERT_PATH}", "key": "${KEY_PATH}" }, "feedback": { "address": "${FEEDBACK_ADDRESS}", "cert": "${CERT_PATH}", "key": "${KEY_PATH}", "interval": 43200, "batchFeedback": true } } } ``` 我们对双引号进行了转义,否则超级简单的渲染引擎`render_template`会将它们删除。然后,我们调用`render_template`,它将获取转义过双引号的文件,并生成最终的配置文件。看起来就像这样: ``` { "webPort": 8300, "mongodbUrl": "mongodb://10.54.199.197/stagingpushserver,mongodb://10.54.199.209?replicaSet=rs0&readPreference=primaryPreferred", "apn": { "connection": { "gateway": "gateway.push.apple.com", "cert": "/certs/apn-cert.pem", "key": "/certs/apn-key.pem" }, "feedback": { "address": "feedback.push.apple.com", "cert": "/certs/apn-cert.pem", "key": "/certs/apn-key.pem", "interval": 43200, "batchFeedback": true } } } ``` 最后,这个脚本通过`/usr/local/bin/pushserver -c /config.json`调用真正的服务,它将加载我们新生成的`config.json`文件。 #### 2. 挂载配置文件 值得注意的是,我们前面生成的配置文件也加载了两个证书,虽然我们也可以把它们当作环境变量传递,如`echo ${CERT} > /certs/apn-cert.pem`,在这个实例中,我们还是以挂载文件的方式来提供。这是一个处理这些以文件进行配置的容器的替代方法。 在启动一个容器时,用户可以挂载本地目录或文件到容器文件系统中,并且这发生在容器进程启动之前。有鉴于此,配置上述容器的另一种方法是在容器启动**之前**运行生成配置文件的脚本,然后把文件挂载到容器里。这种方法的缺点是,用户需要在宿主机上找到一个合适的地方来写入这些配置文件,每个容器可能都要有个不同的版本,然后在销毁容器时正确地清理这些文件。在容器外定制配置文件与在容器内定制相比无更多益处,但是后者会更受欢迎,因为配置文件被包含在容器内。 有时,我们需要容器能感知同一宿主机上的其他容器,并向它们提供服务。例如,提供日志收集服务的容器,它会把所有其他容器的日志发送给类似Kibana的日志聚合器。其他需求可能与这类容器的监控有关。 这类容器需要访问宿主机的Docker进程,以便与它进行通信,并查询现有容器及其配置。 在讨论如何实现这类容器之前,让我们使用一个日志示例来讨论这些容器可以解决的问题类型:我们希望将来自所有容器的日志发送给某些日志聚合服务,而且我们希望可以在一个容器里完成这件事。这要求我们运行一个日志收集进程,如logstash或fluentd,并且配置它为可以从每个容器获取日志。Docker通常将每个容器的日志存储在它自己的目录中,遵循这个模式:`/var/log/docker/containers/$CONTAINER_ID/$CONTAINER_ID-json.log`——这里是json日志。 一个解决方案是构建我们自己的日志收集器,理解这个布局,并可以与Docker通信来查询现有容器的情况。 不过,多数时候用户不想编写自己的日志收集服务,而是选择配置一个现有的。这意味着当宿主机上添加或删除容器时,这个日志收集器的配置会发生改变。幸运的是,已经有一些工具可以根据来自宿主机的Docker服务器的信息来重新构建配置文件。 这类工具之一是[docker-gen](https://github.com/jwilder/docker-gen)。这个工具在Docker提供的容器信息基础上,使用提供的模板来生成配置文件。它所提供的模板语言对多数任务来说都足够强大,它运作的方式是它会**监视**或**轮询**Docker进程以获取容器内的变化(添加、删除等),并在发生变化时从模板重新生成配置文件。 在我们示例中,我们希望重新生成日志收集器配置以便所有容器的日志可以被正确地解析、标记并发送给我们所使用的日志聚合器。 让我们以一个现实世界的例子来看看这是如何工作的,该示例使用fluentd作为日志收集器,使用ElasticSearch/Kibana作为日志聚合器。 首先,我们需要创建我们的日志收集器容器: ``` FROM phusion/baseimage # 设置正确的环境变量 ENV HOME /root # 使用baseimage-docker的init系统 CMD ["/sbin/my_init"] RUN apt-get update && apt-get -y upgrade \ && apt-get install -y curl build-essential ruby ruby-dev wget libcurl4-openssl-dev \ && gem install fluentd --no-ri --no-rdoc \ && gem install fluent-plugin-elasticsearch --no-ri --no-rdoc \ && gem install fluent-plugin-record-reformer --no-ri --no-rdoc ADD . /app WORKDIR /app RUN wget https://github.com/jwilder/docker-gen/releases/download/0.3.6/ docker-gen-linux-amd64-0.3.6.tar.gz \ && tar xvzf docker-gen-linux-amd64-0.3.6.tar.gz \ && mkdir /etc/service/dockergen ADD fluentd.sh /etc/service/fluentd/run ADD dockergen.sh /etc/service/dockergen/run ``` 这个Dockerfile的相关部分是我们安装了fluentd,然后为fluentd安装了一些插件。第一个用于将日志发送给ElasticSearch,另一个是`record-reformer`,用于在发送日志给ElasticSearch之前对其进行转换和标记。最后,我们安装了docker-gen。 由于需要在同一个容器内同时运行docker-gen和fluentd,所以我们需要某种服务管理程序。在这个例子中,我们的镜像是基于`phusion/baseimage`的,这是Ubuntu一个Docker`化`的精简版本。相比常规Ubuntu,这个镜像提供的自定义项之一是使用`runit`作为进程管理程序。Dockerfile的最后两行中,有两个`ADD`指令用于添加docker-gen和fluentd的运行脚本。一旦容器启动,这两个脚本将运行,从而运行并监管docker-gen和fluentd。 docker-gen的启动脚本使用以下设置来启动docker-gen:它将监视Docker宿主机所运行容器的变化,如果出现任何变化,它将从模板`/app/templates/fluentd.conf.tmpl`重新生成`/etc/fluent.conf`文件。一旦完成,它将运行`sv force-restart fluentd`(通过`runit`)强制重启fluentd,这将导致fluentd重载新配置。这是docker-gen的启动文件: ``` #!/bin/sh exec /app/docker-gen \ -watch \ -notify "sv force-restart fluentd" \ /app/templates/fluentd.conf.tmpl \ /etc/fluent.conf ``` fluentd的启动脚本更为直接一些,它只是使用docker-gen生成的配置文件`/etc/fluent.conf`来启动fluentd: ``` #!/bin/sh exec /usr/local/bin/fluentd -c /etc/fluent.conf -v ``` 接下来我们所需的是docker-gen用于生成FluentD配置的模板文件。这是现实世界中的一个详细示例: ``` ## 文件输入 ## 读取标签为docker.container的日志 {{range $key, $value := .}} <source> type tail format json time_key time time_format %Y-%m-%dT%T.%LZ path /var/lib/docker/containers/{{ $value.ID }}/{{ $value.ID }}-json.log pos_file /var/lib/docker/containers/{{ $value.ID }}/{{ $value.ID }}-json.log.pos tag docker.container.{{ $value.Name }} rotate_wait 5 read_from_head true </source> {{end}} {{range $key, $value := .}} <match docker.container.{{ $value.Name }}> type record_reformer renew_record false enable_ruby false tag ps.{{ $value.Name }} <record> hostname {{ $.Env.HOSTNAME }} cluster_id {{ $.Env.CLUSTER_ID }} container_name {{ $value.Name }} image_name {{ $value.Image.Repository }} image_tag {{ $value.Image.Tag }} </record> </match> {{end}} {{range $key, $value := .}} <match ps.{{ $value.Name }}> type elasticsearch host {{ $.Env.ELASTIC_SEARCH_HOST }} port {{ $.Env.ELASTIC_SEARCH_PORT }} index_name fluentd type_name {{ $value.Name }} logstash_format true buffer_type memory flush_interval 3 retry_limit 17 retry_wait 1.0 num_threads 1 </match> {{end}} ``` 这里内容较多,不过主要看一下模板的关键部分。我们为每个容器生成了3个条目:一个`source`类型,两个`match`类型。在这个例子中,我们生成这些条目的方法是:对每个容器进行迭代(`{{range ...}}`)并为其构建相应条目(如`<source ...> ... </source>`)。在一个`range`块内,我们可以使用`$value`变量来读取当前容器的数据,这个变量是一个包含Docker有关该容器所有信息的字典。 例如,在`source`条目中,我们告诉fluentd上哪儿去查找每个容器的日志文件。所有容器的日志都位于`/var/lib/docker/containers`下以该容器ID命名的目录中,其文件名也是以容器ID命名。通过这句来完成: `path /var/lib/docker/containers/{{ $value.ID }}/{{ $value.ID }}-json.log`我们还为来自这个源的日志打上容器名标签,这在之后过滤时将非常有用: `tag docker.container.{{ $value.Name }}`模板里其他条目遵循相同的结构。第一个`match`条目用来重写日志条目并为其添加额外信息,如集群名称和宿主机名称。这两个值都来自于环境变量: ``` hostname {{ $.Env.HOSTNAME }} cluster_id {{ $.Env.CLUSTER_ID }} ``` 我们还添加了来自Docker的其他有用的信息:容器所运行的镜像名称以及镜像标签。这样,我们可以在之后使用镜像名称甚至是镜像版本来过滤日志。在相同镜像的不同版本显示出不同的错误率时,这将非常有帮助,我相信读者也同意这个说法。这是添加了这些标签的代码: ``` image_name {{ $value.Image.Repository }} image_tag {{ $value.Image.Tag }} ``` 最后一个`match`条目会将日志条目发送给ElasticSearch,而它的地址和端口也是来自于环境变量。 现在,使用这个设定,每次一个新容器被创建或被销毁,会使用上述3个条目为每个容器重新生成`/etc/fluent.conf`文件。这样我们从所有容器获取并发送给ElasticSearch的日志就被正确打上时间戳和标记。 在使用Docker镜像时,一个共同的、备受关注的问题是它们到底有多可信。Docker和其他容器提供商正致力于为所下载、运行的镜像提供一个高层次的信任。这种信任来自两个层面。一个是镜像本身是否值得信任,镜像是否由受信任的开发者编写,如Docker公司或Red Hat。另一个是保证所下载的镜像确实是自己想要下载的镜像。截至本书编写时,Docker尚未提供端到端的信任链,不过已经有一部分功能存在。 要保护自己,避免运行包含恶意软件或其他风险的镜像,目前我们最好的选择是自己构建镜像。多数现存镜像都是开放源码的,并通过Dockerfile来生成。与下载镜像相反,复制这个Dockerfile,对其进行检查,然后从该Dockerfile构建镜像。要完全确保镜像不具有危害,用户需要检查所有镜像层,包含基础操作系统层。也就是说,如果一个镜像是基于另一个镜像,而后者又是基于一个知名的操作系统镜像,那么用户需要验证并构建前两层。 尽管容器文件系统是可写的,但最好还是将它们视为只读,并且只在启动时段进行写入,如需要在这段时间生成配置文件。将容器文件系统视为只读的主要原因是这些文件系统比宿主机的文件系统要慢,也因为在容器被销毁时数据极易丢失。很显然,如果一个容器运行着一个数据库,用户需要在某些地方写入数据。这种情况下,用户可以使用容器自身的文件系统,或写入宿主机挂载的数据卷中。 不过,多数容器可能完全不需要写入文件系统,因为它们不保存数据。多数情况下,进程还是会写入文件系统以生成日志。 在容器从业者中一个通用的模式是将日志写入进程的标准输出中,而非写入文件系统。这样,用户就依赖Docker自己的日志收集器来提取那些日志,不再需要对容器的文件系统进行写入。然后,用户在每台宿主机上运行一个日志收集器进程来提取这些Docker生成的日志,并将其发送给一个中央日志服务器进行存档、分析与查询。在运行微服务架构时,不同容器的数量非常巨大并且是动态的,这个模式十分普遍。 在为第三方服务构建镜像时,有时我们无法让服务将日志输出到标准输出中。例如,多数Web服务器就不这么做。不过,也有一个相对简单的方法可以实现相同的行为:将日志文件链接到标准输出中。 例如,`nginx`只会把日志写到文件系统的日志文件中。在这种情况下,我们会指示Nginx继续这么做: `access_log /var/log/nginx/access.log main;`不过在Dockerfile中,我们将`/dev/stdout`链接到该文件中,因此当Nginx写入`access.log`时,它反而是写入到容器的标准输出: `RUN ln -sf /dev/stdout /var/log/nginx/access.log`@#@#@#@#@nav_point_97 理解Docker对叠加文件系统的使用,以及如何构建轻便、可配置、可重用且迎合生态系统的镜像,能够为构建高效的Docker基础设施提供良好的基础。如我们所展示的,有时我们需要考虑不同配置的范式和运行时来进行软件设计,并以此打造对Docker友好的镜像,不过这些额外的付出从长远看是值得的,相比它为我们节省的时间和免去的麻烦要小得多。 Docker基础设施的重要一环是镜像仓库。下一章将详细讲述这个话题。