第5章 配置管理——让一切井然有序

    本章主要内容

    • 使用Dockerfile管理镜像的构建
    • 使用传统的配置管理工具来构建镜像
    • 管理构建镜像时所需的私密信息
    • 缩减镜像的大小,以便更快、更轻量、更安全地交付

    配置管理是一门管理运行时环境的艺术,从而使其稳定且可以预测。像Chef和Puppet这样的工具致力于减轻系统管理员维护多台机器的负担。就一定程度而言,Docker带来的软件环境的隔离性和可移植性同样也减轻了这一负担。即便如此,依然需要用配置管理技术来产出Docker镜像,而这也是一个值得关注的重要领域。

    随着对Docker的不断了解,这些技巧将为读者提供更多工具来构建镜像以实现想要满足的任何配置需求。

    Dockerfile被认为是构建Docker镜像的标准方式。人们常常会疑惑Dockerfile对于配置管理意味着什么。读者也可能会有许多疑问(尤其是在对一些其他配置管理工具有些经验的时候),例如:

    • 如果基础镜像更改会发生什么?
    • 如果更改要安装的包然后重新构建会发生什么?
    • 它会取代Chef/Puppet/Ansible吗?

    事实上,Dockerfile很简单:从给定的镜像开始,Dockerfile为Docker指定一系列的shell命令和元指令,从而产出最终所需的镜像。

    Dockerfile提供了一个普通、简单和通用的语言来配置Docker镜像。使用Dockerfile,用户可以使用任何偏好的方式来达到所期望的最终状态。用户可以调用Puppet,可以从其他脚本里复制,甚至可以从一个完整的文件系统复制!

    让我们先一起考虑一下如何处理Dockerfile带来的一些小挑战,然后再来讨论我们刚提到的更棘手的那些问题。

    Docker允许在任何地方执行命令的潜质意味着在命令行上运行的一些复杂的定制指令或者脚本可以预先配置然后包装到一个打包好的工具。

    容易被曲解的ENTRYPOINT指令便是这其中的一个重要部分。读者将会看到它是怎样创建出封装良好、清晰定义以及足够灵活和有价值的Docker镜像来作为工具的。

    问题

    想要定义容器将会执行的命令,但是将命令的具体参数留给用户。

    解决方案

    使用Dockerfile的ENTRYPOINT指令。

    讨论

    作为演示,不妨试想一下企业里有一个这样的简单场景,有一个常规的管理任务是要清理旧的日志文件。通常这很容易出错,人们可能会意外删错东西,因此我们打算使用一个Docker镜像来降低出现问题的风险。

    下列脚本(用户应该在保存的时候命名为clean_log)会删除超过特定天数的日志,其中具体天数作为一个命令行选项传入:

    1. #!/bin/bash
    2. echo Cleaning logs over $1 days old
    3. find /log_dir -ctime $1 -name log -exec rm {} \;

    注意,该脚本清理的是/log_dir文件夹下的日志。此文件夹只有在运行时挂载了它才会存在。读者可能还会注意到,这里没有检查是否有参数传给该脚本。这样做的原因我们会在后面介绍此技巧时披露。

    现在,让我们在同一目录下创建一个Dockerfile来创建一个镜像,这个Dockerfile包含下面的脚本作为定义好的命令来运行,或者叫入口点(entrypoint):

    1. FROM ubuntu:14.04
    2. ADD clean_log /usr/bin/clean_log 1
    3. RUN chmod +x /usr/bin/clean_log 2)  
    4. ENTRYPOINT [“/usr/bin/clean_log”]
    5. CMD [“7”] 3

    (1)将之前组织好的clean_log脚本添加到镜像里

    (2)将此镜像的入口点定义为clean_log脚本

    (3)设置ENTRYPOINT命令的默认参数(7天)

    最佳实践——总是使用数组模式 读者可能会发现,相比于shell形式(CMD /usr/bin/ command),我们更喜欢用CMDENTRYPOINT的数组形式(如CMD [“/usr/bin/command”])。这是因为,如果是shell形式,它会自动在用户提供的命令前面加上一个/bin/bash -c的命令,这可能会导致难以预料的行为。然而,在某些时候shell形式反而会更有用些(见技巧47)。

    人们常常费解ENTRYPOINTCMD之间到底有什么区别。要理解的关键点是,入口点总会在镜像启动之后运行,即使命令被提供给dockerrun调用。如果用户尝试传入一条命令,它将会作为参数被传给入口点,然后取代在CMD指令部分定义的默认值。用户只能通过显式地传入一个—entrypoint标志给docker run命令来重载入口点。

    这就意味着,通过/bin/bash命令运行镜像将不会提供一个shell,而会将/bin/bash作为clean_log脚本的参数。

    事实上,通过CMD指令定义好默认参数,就意味着不需要再检查是否有传入参数。

    下列命令展示了该如何构建和调用此工具:

    1. docker build -t log-cleaner .
    2. docker run -v /var/log/myapplogs:/log_dir log-cleaner 365

    在构建完该镜像后,通过将/var/log/myapplogs挂载到脚本将用到的目录,并传入365以删除过去一年(而不是一周)的日志文件。

    如果有人尝试以不指定天数这样的不正确方式使用镜像的话,他将会得到一条出错消息:

    1. $ docker run -ti log-cleaner /bin/bash
    2. Cleaning logs over /bin/bash days old
    3. find: invalid argument -name' to-ctime’

    这个例子的确相当简单,不过试想一下,一家企业便可以借此跨资源集中管理脚本,以便可以通过一个私有的注册中心维护和安全地分发脚本。

    镜像可用 可以在Docker Hub上的dockerinpractice/log-cleaner查看并使用该镜像。

    Dockerfile语法简单,功能也有限,它们可以极大地帮助用户理清构建的需求,并且它们可以促进生成镜像的稳定性,但是它们无法确保可重复的构建结果。我们将会探索众多方案中的一个,借以解决这一问题并减少在底层包管理依赖关系改变时带来的意外风险。

    这一技巧有助于避免那些“它昨天还运行得好好的”的尴尬处境,如果读者用过经典的配置管理工具就会感同身受。构建Docker镜像与维护一台服务器本质上有很大的不同,但是一些艰辛的教训依然适用。

    仅支持基于Debian的镜像 本技巧只适用于基于Debian的镜像,如Ubuntu。

    问题

    想要确保deb包是自己期望的版本。

    解决方案

    在一个已验证安装的系统上运行一个脚本来抓取所有依赖软件包的版本,并且获取依赖包的版本。在Dockerfile里安装指定的版本。

    讨论

    针对版本方面的基本检查可以通过在一个已经验证过的系统上调用apt-cache来完成:

    1. $ apt-cache show nginx | grep ^Version:
    2. Version: 1.4.6-1ubuntu3

    然后可以像下面这样在Dockerfile里指定版本:

    1. RUN apt-get -y install nginx=1.4.6-1ubuntu3

    这可能已经足以满足需求。但是这无法保证这一版本的nginx所安装的所有依赖都和之前验证的版本一致。可以通过在参数里添加一个—recurse标志来获取所有依赖的信息:

    1. apt-cache recurse depends nginx

    这一命令的输出内容相当多,因此要获取版本需求的清单也是一件棘手的事情。幸好,我们维护了一个Docker镜像(还有其他的吗?)为用户提供方便。它会输出需要放到Dockerfile里RUN行的内容,以确保所有依赖包的版本都是正确的:

    1. $ docker run -ti dockerinpractice/get-versions vim
    2. RUN apt-get install -y \
    3. vim=2:7.4.052-1ubuntu3 vim-common=2:7.4.052-1ubuntu3 \
    4. vim-runtime=2:7.4.052-1ubuntu3 libacl1:amd64=2.2.52-1 \
    5. libc6:amd64=2.19-0ubuntu6.5 libc6:amd64=2.19-0ubuntu6.5 \
    6. libgpm2:amd64=1.20.4-6.1 libpython2.7:amd64=2.7.6-8 \
    7. libselinux1:amd64=2.2.2-1ubuntu0.1 libselinux1:amd64=2.2.2-1ubuntu0.1 \
    8. libtinfo5:amd64=5.9+20140118-1ubuntu1 libattr1:amd64=1:2.4.47-1ubuntu1 \
    9. libgcc1:amd64=1:4.9.1-0ubuntu1 libgcc1:amd64=1:4.9.1-0ubuntu1 \
    10. libpython2.7-stdlib:amd64=2.7.6-8 zlib1g:amd64=1:1.2.8.dfsg-1ubuntu1 \
    11. libpcre3:amd64=1:8.31-2ubuntu2 gcc-4.9-base:amd64=4.9.1-0ubuntu1 \
    12. gcc-4.9-base:amd64=4.9.1-0ubuntu1 libpython2.7-minimal:amd64=2.7.6-8 \
    13. mime-support=3.54ubuntu1.1 mime-support=3.54ubuntu1.1 \
    14. libbz2-1.0:amd64=1.0.6-5 libdb5.3:amd64=5.3.28-3ubuntu3 \
    15. libexpat1:amd64=2.1.0-4ubuntu1 libffi6:amd64=3.1~rc1+r3.0.13-12 \
    16. libncursesw5:amd64=5.9+20140118-1ubuntu1 libreadline6:amd64=6.3-4ubuntu2 \
    17. libsqlite3-0:amd64=3.8.2-1ubuntu2 libssl1.0.0:amd64=1.0.1f-1ubuntu2.8 \
    18. libssl1.0.0:amd64=1.0.1f-1ubuntu2.8 readline-common=6.3-4ubuntu2 \
    19. debconf=1.5.51ubuntu2 dpkg=1.17.5ubuntu5.3 dpkg=1.17.5ubuntu5.3 \
    20. libnewt0.52:amd64=0.52.15-2ubuntu5 libslang2:amd64=2.2.4-15ubuntu1 \
    21. vim=2:7.4.052-1ubuntu3

    在某些时候,用户的构建将会因为版本不再可用而失败。遇到这种情况时可以看看有哪些包做了改动,然后重新检查一下这些改动,确认是否满足特定镜像的需求。

    这个例子假定用的镜像是ubuntu:14.04。如果用的是Debian的不同发行版,那么可以fork该仓库并修改Dockerfile里的FROM指令,并构建它。该仓库可以在https://github.com/ docker-in-practice/ get-versions.git找到。

    尽管本技巧有助于提升构建的稳定性,但它在安全性方面没有任何建树,因为用户仍然需要从一个无法直接管控的仓库下载软件包。

    使用Dockerfile构建镜像时,在多个文件间替换文本的特定内容的需求并不罕见。有多种方案可以解决这一问题,但是我们这里要介绍的是一个有点儿不太常见的方式,用在Dockerfile里特别方便。

    问题

    想要在构建期间修改多个文件里的特定行。

    解决方案

    使用perl -p -i -e

    讨论

    我们偏好这个命令是有些原因的。

    • sed -i不同(该命令具有类似的语法和效果),这一命令天然支持处理多个文件,即便遇到的问题是修改其中一个文件。这意味着可以在一个目录下加上通配符来执行该命令,便不用再担心在包的后续版本里添加目录时它会突然被破坏。
    • sed一样,搜索和替换命令中的正斜杠可以使用其他字符替换。
    • 它还很容易记忆(我们不妨称之为“perl pie”命令)。

    假定有正则表达式的知识 本技巧假定读者对正则表达式有所了解。如果对正则表达式不熟悉的话,有很多网站可以帮到你。

    下面是该命令的一个典型示例:

    1. perl -p -i -e s/127.0.0.1/0.0.0.0/g

    在这条命令里,-p标志要求perl循环处理看到的所有行,-i标志要求perl即时更新匹配的行内容,而-e标志则要求perl把传入的字符串当作一个perl程序处理。s是perl的一个指令,用来搜索和替换输入里匹配的字符串。这里的127.0.0.1会被替换成0.0.0.0g修饰符则确保所有匹配都被更新,而不只是任何给定行里的第一个匹配。最后,星号()会使这一文件夹下的所有文件都被更新。

    上述命令对Docker容器而言完成的不过是一个很平常的操作。它会在使用一个地址来监听时,将标准的本地主机IP地址(127.0.0.1)替换成一个指示“任意”IPv4地址(0.0.0.0)。许多应用会通过仅监听本地主机地址限制成只有该IP地址才能访问,而通常用户会想要在它们的配置文件里将这一配置修改成“任意”地址,因为从宿主机上访问这些应用时,容器就变成了一台外部主机。

    不能访问容器里的应用? 如果一个Docker容器里的应用,即使端口是打开的,用户仍然无法从宿主机上访问的话,不妨尝试一下在应用的配置文件里把监听的地址修改为0.0.0.0,并重启应用。这可能是应用拒绝访问所致,因为用户不是从本地主机访问它。在运行镜像时加上—net=host(后面技巧97会介绍到)可以帮助验证这一猜测。

    perl -p -i -e(和sed)还有另外一个很不错的功能,那便是:如果有麻烦的字符转义问题,用户可以使用其他字符替换正斜杠符。下面是一个来自本书作者编写的脚本的真实示例,它在默认的Apache站点文件里添加了一些指令。

    这条尴尬的命令

    1. perl -p -i -e s/\/usr\/share\/www/\/var\/www\/html/g /etc/apache2/

    就变成了

    1. perl -p -i -e s@/usr/share/www@/var/www/html/@g /etc/apache2/

    在极少数情况下,用户要匹配或替换/@字符的话可以尝试其他字符,如|或者#

    Dockerfile的设计以及它们产出Docker镜像的结果便是,最终镜像里包含了Dockerfile里每一步的数据状态。在构建镜像的过程中,可能需要复制私密信息来确保构建工作可以顺利进行。这些所谓的私密信息可能是SSH密钥、证书或者密码文件等。在提交镜像前删除这些私密信息的话可能不会提供任何实质性的保护,因为它们将出现在最终镜像的更高分层里,而恶意用户则可以轻松地从镜像中提取它们。解决这一问题的其中一个办法便是将得到的镜像扁平化。

    问题

    想要从镜像的分层历史中移除私密信息。

    解决方案

    基于该镜像创建一个容器,将它导出再导入,然后给它打上最初镜像ID的标签。

    讨论

    为了演示这种做法的可用场景,让我们在一个新目录里创建一个简单的Dockerfile,该目录下藏着一个大秘密。运行mkdir secrets && cd secrets,然后在该目录里创建一个包含如下内容的Dockerfile:

    1. FROM debian
    2. RUN echo My Big Secret >> /tmp/secret_key 1
    3. RUN cat /tmp/secret_key 2) 
    4. RUN rm /tmp/secret_key 3

    (1)在构建里放置一个带有一些私密信息的文件

    (2)对该私密文件做一些事情。当前这个Dockerfile只会列出该文件的内容,但是你的Dockerfile可能会SSH到其他服务器或者在镜像里加密该私密信息

    (3)删除该私密文件

    现在运行docker build -t mysecret .以构建该Dockerfile并给它打标签。

    一旦它完成构建,可以通过docker history命令检查得到的Docker镜像的分层:

    1. $ docker history mysecret 1
    2. IMAGE     CREATED     CREATED BY
    3. SIZE
    4. 55f3c131a35d 25 seconds ago  /bin/sh -c rm /tmp/secret.key
    5. 0 B 2
    6. 5b376ff3d7cd 26 seconds ago  /bin/sh -c cat /tmp/secret_key
    7. 0 B
    8. 5e39caf7560f 27 seconds ago  /bin/sh -c echo My Big Secret >> /tmp/secre
    9. 14 B 3)             
    10. c90d655b99b2 2 weeks ago    /bin/sh -c #(nop) CMD [/bin/bash]
    11. 0 B
    12. 30d39e59ffe2 2 weeks ago    /bin/sh -c #(nop) ADD file:3f1a40df75bc5673ce
    13. 85.01 MB           
    14. 511136ea3c5a 20 months ago 4
    15. 0 B 5

    (1)使用刚创建的镜像的名称运行docker history命令

    (2)这一分层是删除密钥的地方

    (3)这一分层是添加密钥的地方

    (4)在这一分层添加了Debian文件系统。注意,该分层是历史记录里最大的一个

    (5)最开始(空的)分层

    现在试想一下用户从一个公开的注册中心下载了这一镜像。他可以检索镜像分层的历史然后运行如下命令列出私密信息:

    1. $ docker run 5b376ff3d7cd cat /tmp/secret_key
    2. My Big Secret

    这里我们运行了一个特定的分层然后将它构造出来,cat我们在更高分层里已经删除的那个密钥的内容。正如所见,文件是可以访问的。

    至此,用户有一个里面藏有秘密的“危险”容器,我们已经见证了的确可以“黑”到里面的私密信息。要让这个镜像变得安全,需要将该镜像扁平化处理。这意味着用户可以在该镜像里保留相同的数据但是会删除中间分层的信息。为了达成这一目的,需要将该镜像导出为一个简单运行的容器然后再重新导入并给得到的镜像打上标签:

    1. $ docker run -d mysecret /bin/true (1
    2. 28cde380f0195b24b33e19e132e81a4f58d2f055a42fa8406e755b2ef283630f
    3. $ docker export 28cde380f | docker import mysecret 2)      
    4. $ docker history mysecret
    5. IMAGE       CREATED     CREATED BY SIZE
    6. fdbeae08751b 13 seconds ago       85.01 MB 3

    (1)运行一个简单的命令让容器可以快速退出,因为并不需要它处于运行状态

    (2)运行docker export,把容器ID当作参数并输出文件系统的内容的一个TAR文件。这一操作被管道到docker import,它会以TAR文件的内容作为输入,基于这些内容创建一个镜像

    (3)docker history的输出现在只显示最后那一组文件的分层

    传给docker import命令的-参数指明用户想要从命令的标准输入读取TAR文件内容。docker import的最后一个参数指明该如何给导入的镜像打标签。在这个例子里它会覆盖之前的标签。

    由于现在镜像里只有一个分层,因此就没有藏着秘密的分层记录。现在再也无法从镜像提取任何秘密了。

    本书里(还有互联网上)绝大部分Dockerfile示例使用的都是基于Debian的镜像,而软件开发的现实决定了许多人不会专门做这些打包的事情。

    好在有现成的工具可以帮助用户实现这一点。

    问题

    想要安装一个外来的发行版的软件包。

    解决方案

    使用一个基于alien的Docker镜像转换软件包。

    讨论

    alien是一款命令行工具,是专为转换不同格式的软件包文件设计的,如表5-1所示。我们不止一次遇到过需要让外来软件包管理系统下的软件包正常工作,例如,.deb用在centos中,.rpm文件用在非Red Hat系的系统。

    表5-1 alien支持的包格式

    扩 展 名

    描  述

    .deb

    Debian包

    .rpm

    Red Hat包管理

    .tgz

    Slackware Gzip压缩的TAR文件

    .pkg

    Solaris pkg包

    .slp

    Stampede包

    不涉及Solaris包和Stampede包 出于本技巧的初衷,没有完全覆盖到Solaris包和Stampede包。Solaris要求安装Solaris特有的软件,而Stampede则是一个已经废弃的项目。

    在本书的研究过程中我们发现,在非Debian系的发行版上安装alien可能会有些费劲儿。这是一本Docker书,我们自然决定以Docker镜像的形式提供一个转换工具。作为一个小福利,这一工具用到了技巧40中介绍的ENTRYPOINT命令,让用户可以更加便利地使用它。

    举个例子,让我们来看看eatmydata这个软件包,在技巧56里会用到它:

    1. $ mkdir tmp && cd tmp 1
    2. $ wget \
    3. http://mirrors.kernel.org/ubuntu/pool/main/libe/libeatmydata/
    4. eatmydata_26-2_i386.deb 2
    5. $ docker run -v $(pwd):/io dockerinpractice/alienate 3)      
    6. Examining eatmydata_26-2_i386.deb from /io
    7. eatmydata_26-2_i386.deb appears to be a Debian package 4
    8. eatmydata-26-3.i386.rpm generated
    9. eatmydata-26.slp generated
    10. eatmydata-26.tgz generated
    11. ===================================================================
    12. /io now contains:
    13. eatmydata-26-3.i386.rpm
    14. eatmydata-26.slp
    15. eatmydata-26.tgz
    16. eatmydata_26-2_i386.deb
    17. ===================================================================
    18. $ ls 1 5)          
    19. eatmydata_26-2_i386.deb
    20. eatmydata-26-3.i386.rpm
    21. eatmydata-26.slp
    22. eatmydata-26.tgz

    (1)创建一个空的工作目录

    (2)获取想要转换的包文件

    (3)运行dockerinpractice/alienate镜像,将当前目录挂载到容器的/io路径下。容器会检查该目录,尝试转换找到的任意有效文件

    (4)容器通知用户它运行Alien包装脚本时的行为

    (5)文件已经被转换为RPM、Slackware tgz和Stampede文件

    或者,也可以直接将URL传给docker run命令:

    1. $ mkdir tmp && cd tmp
    2. $ docker run -v $(pwd):/io dockerinpractice/alienate \
    3. http://mirrors.kernel.org/ubuntu/pool/main/libe/
    4. libeatmydata/eatmydata_26-2_i386.deb
    5. wgetting http://mirrors.kernel.org/ubuntu/pool/main/libe/
    6. libeatmydata/eatmydata_26-2_i386.deb
    7. 2015-02-26 10:57:28 http://mirrors.kernel.org/ubuntu/pool/main/libe/
    8. libeatmydata/eatmydata_26-2_i386.deb
    9. Resolving mirrors.kernel.org (mirrors.kernel.org)…
    10. 198.145.20.143, 149.20.37.36, 2001:4f8:4:6f:0:1994:3:14,
    11. Connecting to mirrors.kernel.org (mirrors.kernel.org)
    12. |198.145.20.143|:80 connected.
    13. HTTP request sent, awaiting response 200 OK
    14. Length: 7782 (7.6K) [application/octet-stream]
    15. Saving to: eatmydata_26-2_i386.deb
    16.    0K …….                      100% 2.58M=0.003s
    17. 2015-02-26 10:57:28 (2.58 MB/s) - eatmydata_26-2_i386.deb saved [7782/7782]
    18. Examining eatmydata_26-2_i386.deb from /io
    19. eatmydata_26-2_i386.deb appears to be a Debian package
    20. eatmydata-26-3.i386.rpm generated
    21. eatmydata-26.slp generated
    22. eatmydata-26.tgz generated ====================================================
    23. /io now contains:
    24. eatmydata-26-3.i386.rpm
    25. eatmydata-26.slp
    26. eatmydata-26.tgz
    27. eatmydata_26-2_i386.deb ========================================================
    28. $ ls -1
    29. eatmydata_26-2_i386.deb
    30. eatmydata-26-3.i386.rpm
    31. eatmydata-26.slp
    32. atmydata-26.tgz

    如果想在自己的容器里运行alien,可以通过这个命令启动容器:

    1. docker run -ti entrypoint /bin/bash dockerinpractice/alienate

    不确保一定奏效 alien这款工具的定位是“尽力而为”,它并不保证一定能够将提供的包转换成功。

    用户可能会发现自己遇到这样的场景:有人用一个Dockerfile创建了一个可以访问的镜像,但最初的Dockerfile却遗失了。我们发现自己不止一次遇到过这种情况。能恢复构建步骤相关信息可以避开冗长的自己重新探索的过程。

    问题

    有一个镜像,想要逆向工程得到最初的Dockerfile。

    解决方案

    使用docker history命令并通过查看分层的方式尝试确定更改过的地方。

    讨论

    尽管一个Docker镜像不是每次都能完全逆向工程,但是倘若它本质上是通过一个Dockerfile创建出来的,这会是一个很好的弄懂它是怎样装配到一起的机会(这是一个机会,很有希望借此按需重建镜像)。

    在本技巧中我们打算采用如下Dockerfile作为示例。我们尽量用最少的步骤覆盖尽可能多的不同类型的指令。读者会去构建这个Dockerfile,然后运行一个简单的shell命令来了解这一技巧是如何工作的,最终我们会看一个更接近容器化的解决方案。

    1. FROM busybox
    2. MAINTAINER ian.miell@gmail.com
    3. ENV myenvname myenvvalue
    4. LABEL mylabelname mylabelvalue
    5. WORKDIR /opt
    6. RUN mkdir -p copied
    7. COPY Dockerfile copied/Dockerfile
    8. RUN mkdir -p added
    9. ADD Dockerfile added/Dockerfile
    10. RUN touch /tmp/afile
    11. ADD Dockerfile /
    12. EXPOSE 80
    13. VOLUME /data
    14. ONBUILD touch /tmp/built
    15. ENTRYPOINT /bin/bash
    16. CMD -r

    LABEL指令可能无法工作 在编写本书期间LABEL还是Docker相对较新的一条指令,因此在用户的安装版本里可能还没有这一指令。

    首先,用户需要构建这个示例镜像,将得到的镜像命名为reverseme

    1. $ docker build -t reverseme .
    1.shell解决方案

    基于shell的实现方案所需的大部分指令都在这里,与接下来的容器化解决方案相比不那么完备。举个例子,这一方案使用docker inspect命令来提取元数据。

    jq是必需的 要运行这一解决方案,用户需要安装jq程序。它允许用户查询和操作JSON数据。

    1. docker history reverseme | \ 1)   
    2. awk ‘{print $1}’ | \ 2
    3. grep -v IMAGE | \ 3)              
    4. tac|\ 4
    5. sed s/(.)/docker inspect \1 | \ 5
    6. jq -r \’.[0].ContainerConfig.Cmd[2] | tostring\’/“ | \
    7. sh | \ 6
    8. sed s/^#(nop) //‘ (7)

    (1)检索组成指定镜像的分层

    (2)从docker history的输出里检索出每一分层的镜像ID

    (3)排除标题那行(带有“IMAGE”字样的这一行),因为它是不相关的

    (4)将列出的镜像翻转回Dockerfile的顺序(tac是cat的反向)

    (5)拿到镜像ID然后构造出一条命令,运行它的话可以检索Docker镜像分层元数据里记录的执行命令。将docker inspect的输出内容管道到一个jq命令,它会提取出用来创建镜像分层的具体命令,像Docker元数据里已经记录的那样

    (6)执行由sed命令输出的docker inspect命令

    (7)输出那些不会更改文件系统的指令——它们都带有#(nop)前缀

    输出内容看上去将会是下面这样的:

    1. MAINTAINER Jérôme Petazzoni <jerome@docker.com>
    2. ADD file:8cf517d90fe79547c474641cc1e6425850e04abbd8856718f7e4a184ea878538 in /
    3. CMD [“/bin/sh”]
    4. MAINTAINER ian.miell@gmail.com
    5. ENV myenvname=myenvvalue
    6. WORKDIR /opt
    7. mkdir -p copied
    8. COPY file:d0fb99565b15f8dfec37ea1cf3f9c4440b95b1766d179c11458e31b5d08a2ced in
    9. copied/Dockerfile
    10. mkdir -p added
    11. ADD file:d0fb99565b15f8dfec37ea1cf3f9c4440b95b1766d179c11458e31b5d08a2ced in
    12. added/Dockerfile
    13. touch /tmp/afile
    14. ADD file:d0fb99565b15f8dfec37ea1cf3f9c4440b95b1766d179c11458e31b5d08a2ced in /
    15. COPY dir:9cc240dcc0e31ce1b68951d230ee03fc6d3b834e2ae459f4ad7b7d023845e834 in /
    16. COPY file:97bc58d5eaefdf65278cf82674906836613be10af02e4c02c81f6c8c7eb44868 in /
    17. EXPOSE 80/tcp
    18. VOLUME [/data]
    19. ONBUILD touch /tmp/built
    20. ENTRYPOINT [/bin/sh -c /bin/bash]
    21. CMD [/bin/sh -c -r]

    上述输出看上去和我们最初的Dockerfile大同小异。FROM命令被3条用来创建BusyBox镜像分层的命令取代。ADDCOPY命令指向了一个校验和以及文件被解压的位置。这是因为最开始的构建上下文不会在元数据里保存。最后,CMDENTRYPOINT指令被修改成规范的方括号形式。

    什么是构建上下文 Docker构建上下文是指docker build命令里传入的目录的位置及目录里的一组文件。该上下文也是Docker在Dockerfile里用来查找ADD或者COPY对应文件的地方。

    因为缺少构建上下文会导致ADDCOPY指令无效,所以这个Dockerfile不能按照原来的效果运行。但是,如果用户在其逆向工程得到的Dockerfile里也有这些内容的话,他就必须尝试去找出从上下文信息里添加的那一个或一组文件。举个例子,在上面的输出中,如果用户以reverseme镜像运行起一个容器,然后再查看复制的/Dockerfile,应该就可以提取所需的文件并将它添加到新的构建上下文中。

    2.容器化的解决方案

    尽管前面的方案是一个很有用而且很有指导意义的获取用户感兴趣的镜像相关信息的方式(并且比较容易根据自己需求进行修改),但是还有一种更为优雅的方式可以实现相同的效果——这可能也是一种更容易维护的方式。作为一个小福利,这一解决方案也为你提供了(如果可以的话)一条和用户最初那个Dockerfile中的FROM类似的FROM命令:

    1. $ docker run -v /var/run/docker.sock:/var/run/docker.sock \
    2.  dockerinpractice/dockerfile-from-image reverseme
    3. FROM busybox:buildroot-2014.02
    4. MAINTAINER ian.miell@gmail.com
    5. ENV myenvname=myenvvalue
    6. WORKDIR /opt
    7. RUN mkdir -p copied
    8. COPY file:43a582585c738bb8fd3f03f29b18caaf3b0829d3ceb13956b3071c5f0befcbfc \
    9. in copied/Dockerfile
    10. RUN mkdir -p added
    11. ADD file:43a582585c738bb8fd3f03f29b18caaf3b0829d3ceb13956b3071c5f0befcbfc \
    12. in added/Dockerfile
    13. RUN touch /tmp/afile
    14. ADD \
    15. file:43a582585c738bb8fd3f03f29b18caaf3b0829d3ceb13956b3071c5f0befcbfc in /
    16. EXPOSE 80/tcp
    17. VOLUME [/data]
    18. ONBUILD touch /tmp/built
    19. ENTRYPOINT [/bin/sh -c /bin/bash]
    20. CMD [/bin/sh -c -r]

    这一技巧真的是解决了我们工作中的好几个痛点!

    仅适用于Dockerfile创建的镜像 如果镜像是用Dockerfile正确创建出来,这一技巧应该能够按描述的方式工作。倘若镜像是手工制作然后再提交的,镜像之间的差异将不会出现在镜像的元数据里。

    现在我们将继续介绍Dockerfile如何与更传统的配置管理工具一起工作。

    我们在这里一起看看使用make的传统配置管理,展示如何使用现有的Chef脚本通过Chef Solo来置备镜像,以及使用一个shell脚本框架来帮助不精通Docker的用户构建镜像。

    有时候,用户可能会发现有些Dockerfile限制了自己的构建流程。举个例子,如果限制自己运行docker build命令,就无法产生任何输出文件,并且无法在Dockerfile中定义变量[1]

    这种附加工具化的需求可以通过一些工具(包括纯shell脚本)来实现。在这一技巧里,我们将一起来看看可以怎样结合老牌的make工具与Docker一起工作。

    问题

    想要在docker build执行过程中增加额外的任务。

    解决方案

    make里封装镜像的创建。

    讨论

    为防用户之前没有make使用经验,我们在这里对它进行一些简单的介绍,make是一款工具,它需要一个或多个输入文件并会产出一个输出文件,但是它也可以用作一个任务运行器。下面是一个简单的示例(注意所有缩进都必须是制表符):

    1. .PHONY: default createfile catfile 1)  
    2. default: createfile 2)           
    3. createfile: x.y.z 3)    
    4. catfile: 4)     
    5.    cat x.y.z
    6. x.y.z: 5)                     
    7.    echo About to create the file x.y.z
    8.    echo abc > x.y.z

    (1)默认情况下,make会假定所有目标均是将被任务创建出来的文件名,使用.PHONY表明这不是任务的真正名称

    (2)按照惯例,Makefile中的第一个目标是default。如果在运行的时候没有指定一个明确的目标,make将会选取文件中的第一个目标。可以看到,因为createfile是default的唯一依赖,default将会执行它

    (3)createfile是一个伪任务,它依赖x.y.z任务

    (4)catfile是一个伪任务,它运行单条命令

    (5)x.y.z是一个文件任务,会运行两条命令并创建目标x.y.z文件

    空距很重要 一个Makefile里的所有缩进都必须是制表符,并且目标里的每条命令都是在不同的shell里运行的(所以环境变量不会被传递过去)。

    一旦在名为Makefile的文件中定义了上述内容,便可以使用像make createfile这样的命令去调用任意目标。

    现在我们可以在Makefile中查看一些有用的模式——接下来要讨论的目标都将是伪任务,因为它很难(尽管可以)通过追踪文件的变动来自动触发Docker构建。Dockerfile会对镜像分层进行缓存,因此构建往往会很快。

    第一步就是运行一个Dockerfile。因为Makefile是由shell命令组成,所以这一点很容易办到:

    1. base:
    2.   docker build -t corp/base .

    上述命令做的工作带来的一些正常变动正是用户所期许的结果(例如,将文件通过管道传递给docker build以去掉上下文,或是用-f指定采用不同命名的Dockerfile),而且用户可以使用make的依赖功能,在必要时自动构建基础镜像(在FROM中使用的那个)。例如,如果用户在一个叫repos的子目录下迁出几个仓库(这样也容易做make),用户可以像下面这样添加一个目标:

    1. app1: base
    2.   cd repos/app1 && docker build -t corp/app1 .

    这样做的缺点是,每当基础镜像需要重新构建,Docker就需要上传一个包含所有依赖仓库的构建上下文。可以通过显式地传入一个作为构建上下文的TAR文件给Docker来解决这一问题:

    1. base:
    2.   tar -cvf - file1 file2 Dockerfile | docker build -t corp/base .

    如果用户目录内包含大量与构建无关的文件,那么这种依赖的显式声明语句将会带来一个显著的速度方面的提升。如果用户想要将所有构建依赖保留在不同的目录里,可以稍微修改一下这个目标:

    1. base:
    2.   tar transform s/^deps\///‘ -cf - deps/ Dockerfile | \
    3. docker build -t corp/base .

    在这里,用户可以将deps目录下的所有内容添加到构建上下文中,然后使用—transform选项压缩tar包(Linux上的最新tar版本支持),这样便可以从文件名中除去任何前导“deps/”。在这个例子里,更好的办法是将deps和Dockerfile放在各自的目录中以允许正常的docker build,但是了解这种高级用法很有价值,因为它可以在一些最不可能的地方派上用场。在使用这一方案之前往往要考虑清楚,毕竟它会增加构建流程的复杂度。

    简单的变量替换是一件相对简单的事情,但是(如之前的—transform)在使用它之前还是得考虑清楚——Dockerfile之所以故意不支持变量,就是为了保持构建是易于重现的。这里我们将用到传给make的一些变量,然后使用sed替换,不过用户也可以按照自己的喜好来传参和替换:

    1. VAR1 ?= defaultvalue
    2. base:
    3.   cp Dockerfile.in Dockerfile
    4.   sed -i s/{VAR1}/$(VAR1)/‘ Dockerfile
    5.   docker build -t corp/base .

    Dockerfile将在每次基础目标运行时被重新生成,而且用户可以通过添加更多的sed -i条目添加更多的变量替换。要覆盖VAR1的默认值,可以执行make VAR1 = newvalue base。倘若变量里面包含斜杠的话,用户可能需要另外指定一个sed分隔符,如sed -i’s#VAR1}#$(VAR1)#‘Dockerfile。

    最后,如果用户一直使用Docker作为构建工具,那便需要知道怎样才能从Docker中获取文件。我们将介绍几种不同的可选方案,具体取决于实际用例场景:

    1. singlefile: base
    2.   docker run rm corp/base cat /path/to/myfile > outfile
    3. multifile: base
    4.   docker run rm -v $(pwd)/outdir:/out corp/base sh \
    5. -c cp -r /path/to/dir/ /out/“

    在这里,singlefile对一个文件执行cat然后管道输出到一个新文件。这个方案具有自动设置正确的文件拥有者的优点,但是对于多个文件的处理就会变得很麻烦。推荐的多文件方案则是在容器里挂载一个卷并将所有文件从一个目录复制到该卷。用户可以使用chown命令来设置文件的真正拥有者,但是别忘了在调用时可能需要带上sudo

    Docker项目本身从源代码构建Docker时便是用的挂载卷的方案。

    Docker新手们常常疑惑的一件事情便是,Dockerfile是否是唯一被支持的配置管理工具,以及现有配置管理工具是否应该移植到Dockerfile。这些观点都是不对的。

    尽管Dockerfile被设计成是一种简单、可移植的镜像置备手段,但是它也足够灵活,允许任何其他的配置管理工具接管。简而言之,如果可以在终端里运行它,便可以在Dockerfile中运行它。

    这里作为演示,我们将展示如何在Dockerfile中启动并运行Chef(可能是最成熟的配置管理工具)。使用像Chef这样的工具可以减少配置镜像的工作量。

    问题

    想要通过使用Chef来减少配置工作。

    解决方案

    在容器里安装Chef然后运行recipe来置备它。

    讨论

    在这个示例里,我们将使用Chef置备一个简单的“Hello World!”Apache网站。通过这个例子可以给读者直观的感受,即Chef在配置管理方面能做些什么。

    针对这个例子,我们将使用Chef Solo,它不需要配置外部的Chef服务器。如果读者对Chef很熟悉的话,这个例子可以很容易适配现有脚本。

    我们将演示这一Chef示例的创建过程,但如果想要得到可执行代码,这里有一个Git仓库可供下载。要下载它的话,运行下面这条命令:

    1. git clone https://github.com/docker-in-practice/docker-chef-solo-example.git

    我们将从一个小目标做起,利用Apache设置一个一访问它便输出“Hello World!”的Web服务器。该站点工作在mysite.com下,而且在镜像上会设置一个mysiteuser用户。

    首先,创建一个目录并设定好Chef配置所需的文件:

    1. $ mkdir chef_example
    2. $ cd chef_example
    3. $ touch attributes.json 1)     
    4. $ touch config.rb 2
    5. $ touch Dockerfile 3
    6. $ mkdir -p cookbooks/mysite/recipes 4)     
    7. $ touch cookbooks/mysite/recipes/default.rb
    8. $ mkdir -p cookbooks/mysite/templates/default 5
    9. $ touch cookbooks/mysite/templates/default/message.erb

    (1)Chef的属性文件,它定义了这个镜像(或者用Chef的说法是节点)的一些变量,包含这个镜像的执行列表里的recipe,以及其他的一些信息

    (2)Chef的配置文件,它设置一些Chef配置相关的基础变量

    (3)将用来构建镜像的Dockerfile

    (4)创建默认的recipe文件夹,在这里面保存构建该镜像的Chef指令

    (5)针对动态配置的内容创建一些模板

    attributes.json的内容如代码清单5-1所示。

    代码清单5-1 attributes.json

    1. {
    2.   run_list”: [
    3.       recipe[apache2::default]”,
    4.       recipe[mysite::default]”
    5.   ]
    6. }

    上述文件列出了要运行的recipe。apache2 recipe将会从一个公共仓库获取,而mysite recipe则在本地编写。

    接下来,config.rb里放了一些基础信息,如代码清单5-2所示。

    代码清单5-2 config.rb

    1. base_dir    “/chef/“
    2. file_cache_path base_dir + cache/“
    3. cookbook_path  base_dir + cookbooks/“
    4. verify_api_cert true

    上述文件设置了相关位置的一些基础信息,并添加了配置参数verify_api_cert来去掉不相干的错误。

    至此,我们终于收获了劳动成果——该镜像的Chef recipe。代码块里每一个由end结尾的小节定义了一个Chef资源(见代码清单5-3)。

    代码清单5-3 cookbooks/mysite/recipes/default.rb

    1. user mysiteuser do 1)  
    2.   comment mysite user
    3.   home “/home/mysiteuser
    4.   shell “/bin/bash
    5.   supports :manage_home => true
    6. end
    7. directory “/var/www/html/mysite do 2)   
    8.   owner mysiteuser
    9.   group mysiteuser
    10.   mode 0755
    11.   action :create
    12. end
    13. template “/var/www/html/mysite/index.html do 3)    
    14.   source message.erb
    15.   variables(
    16.     :message => Hello World!”
    17.   )
    18.   user mysiteuser
    19.   group mysiteuser
    20.   mode 0755
    21. end
    22. web_app mysite do 4)    
    23.   server_name mysite.com
    24.   server_aliases [“www.mysite.com”,”mysite.com”] 5)   
    25.   docroot “/var/www/html/mysite
    26.   cookbook apache2
    27. end

    (1)创建一个用户

    (2)创建一个目录来放置Web内容

    (3)定义了一个将会放到 Web 文件夹下的文件。根据source属性中定义的模板创建该文件

    (4)为apache2定义一个Web应用

    (5)在真实场景里,用户必须将这些引用从mysite改成自己的站点名称。如果用户是在自己的宿主机上访问或测试的话那就没问题了

    网站的内容包含在模板文件里。Chef会去读取其中一行,如代码清单5-4所示,将其替换成来自config.rb的“Hello World!”消息,然后再将替换后的文件写到模板目标(/var/www/html/mysite/ index.html)。这个例子中用到的模板语言在这里不做详细介绍。

    代码清单5-4 cookbooks/mysite/templates/default/message.erb

    1. <%= @message %>

    最后,将所有内容和Dockerfile放到一起,它会设置Chef的前置要求然后运行Chef来配置镜像,如代码清单5-5所示。

    代码清单5-5 Dockerfile

    1. FROM ubuntu:14.04
    2. RUN apt-get update && apt-get install -y git curl
    3. RUN curl -L \
    4. https://opscode-omnibus-packages.s3.amazonaws.com/
    5. ubuntu/12.04/x86_64/chefdk_0.3.5-1_amd64.deb \
    6. -o chef.deb
    7. RUN dpkg -i chef.deb && rm chef.deb 1)         
    8. COPY . /chef 2)         
    9. WORKDIR /chef/cookbooks
    10. RUN knife cookbook site download apache2 3
    11. RUN knife cookbook site download iptables       
    12. RUN knife cookbook site download logrotate
    13. RUN /bin/bash -c for f in $(ls gz); do tar -zxf $f; rm $f; done 4)     
    14. RUN chef-solo -c /chef/config.rb -j /chef/attributes.json 5)    
    15. CMD /usr/sbin/service apache2 start && sleep infinity 6

    (1)下载并安装Chef。如果这一下载方式不起作用,可以检查之前在这一技巧的讨论中提到的docker-chef- solo-example的最新代码,因为这里可能需要更新的版本

    (2)将所在文件夹下的内容复制到镜像上的/chef 文件夹

    (3)切换到cookbooks文件夹然后使用Chef的knife工具下载apache2 cookbook及相关依赖的压缩包

    (4)解压下载完的压缩包然后删除它们

    (5)运行chef命令配置镜像。把事先创建好的属性和配置文件传给它

    (6)定义镜像默认的启动命令。sleep infinity命令可以确保容器不会在service命令完成任务后立刻退出

    现在可以构建并运行镜像:

    1. docker build -t chef-example .
    2. docker run -ti -p 8080:80 chef-example

    如果读者现在浏览http://localhost:8080,应该能看到“Hello World!”的字样。

    Docker Hub访问超时 如果Chef构建需要很长时间,而且用的是Docker Hub工作流的话,构建可能会出现超时。如果发生这种情况,用户可以在一台受自己管控的机器上完成构建,为支持的服务买单,或者是把构建步骤拆分成更小的单元,这样一来Dockerfile里每个单独步骤返回的时间便会更短。

    这个例子虽然很简单,但是使用这一方案的好处是显而易见的。通过一些相对明了的配置文件,将镜像转换成所需状态的具体细节交由配置管理工具处理。这并不意味着可以忘记配置的细节,更改变量值的话还是要求理解其语义的,这样可以确保不会把事情搞砸。但是,这种方法的确可以节省很多的时间和精力,特别是那些不需要了解太多细节的项目。

    我们已经介绍了构建Docker镜像的几种方式,但是唯一一个从头设计来利用Docker的功能的便是Dockerfile。然而,这里还有一些可供选择的替代方案,它们可以让那些对Docker不感兴趣的开发者从中解脱出来,或是可以给构建过程提供更强大的能力。

    问题

    想要给自己的用户提供一种手段,使他们无须了解Docker便可以创建出一个Docker镜像。

    解决方案

    使用Red Hat的Source to Image(S2I或者STI)框架来构建Docker镜像。

    讨论

    Source to Image是通过将源代码存放到一个单独定义的负责构建镜像的Docker镜像来创建所需Docker镜像的一种手段。

    读者可能想知道为什么会想出这么一种构建方法。主要原因便是它允许应用开发人员对自己的代码进行修改而无须关心Dockerfile甚至Docker镜像的细节。如果镜像交付到了一个aPaaS(应用程序平台即服务)平台,个别工程师不需要懂Docker也可以为项目贡献代码。这在企业环境里相当有用,在那里有大群人专注于特定领域,而且不用直接关注他们项目的构建过程。

    S2I也称为STI 从源到镜像的构建方法在源代码和文档里有两个众所周知的名字:最开始是STI,新的名字是S2I。它们是一回事。

    图5-1在核心轮廓里展示了S2I的工作流。

    一旦流程建立起来,工程师们只需要专注他们希望对源代码做出的变更,致力于将它推广到不同的环境。其他一切事情则交由启动该流程的sti工具驱动。

    1.额外收益

    这一方案的优点体现在以下几个方面。

    • 灵活性——这个过程可以很容易地嵌到任何现有的软件交付流程中,而且它几乎可以使用任意Docker镜像作为它的基础镜像层。
    • 速度——这种构建方法可以比Dockefile构建更快,因为任意数量的复杂操作都可以添加到构建过程中而无须在每个步骤创建出一个新的分层。S2I还使用户能够在构建之间复用组件以节省时间。
    • 业务解耦——由于源代码和Docker镜像均是清晰且明确分离的,开发人员可以专注于代码而基础架构团队则专注于Docker镜像及其交付。因为基础的底层镜像和代码是分离的,一些升级和补丁也更容易交付。

    5

    图5-1 从源到镜像的工作流

    • 安全性——这个流程可以将构建中执行的操作限制为特定用户,而Dockerfile则允许以root身份运行任意命令。
    • 生态系统——此框架的结构允许用户建立一个镜像和代码分离模式的共享生态系统,这更易于大型环境的运维。

    本技巧将展示如何完成镜像构建这样的一个模式,尽管它很简单而且在某些方面有所限制。我们的应用模式将包含:

    • 包含一个shell脚本的源代码;
    • 一个用来创建镜像的构建器,该镜像会获取shell脚本,使它可运行,然后运行它。
    2.创建自己的S2I镜像

    创建自己的S2I镜像需要以下几个步骤:

    (1)启动一个S2I开发环境;

    (2)创建自己的Git项目;

    (3)创建构建器镜像;

    (4)构建应用程序镜像。

    一旦镜像被创建出来,对其做修改和重新构建就很简单了。

    3.启动一个S2I开发环境

    为了帮助确保建立一种一致的体验,用户可以使用一个受维护的环境来开发自己的S2I构建镜像和项目:

    1. $ docker run-ti \
    2. -v /var/run/docker.sock:/var/run/docker.sock \ 1
    3. dockerinpractice/shutit-s2i 2

    (1)确保宿主机的Docker守护进程在容器里可用(见技巧25)

    (2)使用一个受维护的S2I构建环境

    有问题?启用了SELinux? 如果读者使用的是一个开启了SELinux的环境,那么在容器里执行Docker可能会遇到问题。详情见技巧88。

    4.创建自己的Git项目

    用户可以使用一个在其他地方构建然后放到GitHub上的Git项目,但是为了保证这个例子的简单性和独立性,我们会在S2I开发环境本地创建一个项目。正如前面所讲,源代码里包含一个向终端输出“Hello World”的shell脚本:

    1. mkdir /root/myproject
    2. cd /root/myproject
    3. git init
    4. git config global user.email you@example.com
    5. git config global user.name Your Name
    6. cat > app.sh <<< echo Hello World’”
    7. git add .
    8. git commit -am Initial commit
    5.创建构建器镜像

    为了创建构建器镜像,我们打算用sti创建一个脚手架然后在它上面做修改:

    1. sti create sti-simple-shell /opt/sti-simple-shell
    2. cd /opt/sti-simple-shell

    这条S2I命令会创建出若干文件。为了让我们的工作流能够运行起来,我们要重点编辑如下文件:

    • Makefile;
    • Dockerfile;
    • .sti/bin/assemble;
    • .sti/bin/run。

    先拿Dockerfile来说,将其内容修改成下面这些:

    1. FROM openshift/base-centos7 1)    
    2. RUN chown -R default:default /opt/openshift 2) 
    3. COPY ./.sti/bin /usr/local/sti 3)    
    4. RUN chmod +x /usr/local/sti/ 4) 
    5. USER default 5

    (1)使用标准的OpenShift基础centos7镜像。它里面事先创建好了一个默认用户

    (2)将默认的Openshift代码位置的拥有者修改成默认用户

    (3)将S2I脚本复制到一个S2I构建的默认位置

    (4)确保S2I脚本是可执行的

    (5)让构建器镜像默认情况下使用事先创建好的默认用户

    接下来需要创建汇编脚本,它负责获取源代码并对其进行编译以便可以运行。下面这个bash脚本是一个可供使用的版本,尽管有所简化但是功能俱全:

    1. #!/bin/bash –e (1)   
    2. if [ $1 = “-h ]; then 2)     
    3.   exec /usr/local/sti/usage
    4. fi                  
    5. if [ $(ls /tmp/artifacts/ 2>/dev/null)” ]; then 3
    6.  echo “—-> Restoring build artifacts
    7.  mv /tmp/artifacts/ ./
    8. fi                             
    9. echo “—-> Installing application source 4) 
    10. cp -Rf /tmp/src/. ./              
    11. echo “—-> Building application from source 5
    12. chmod +x /opt/openshift/src/app.sh

    (1)作为一个bash脚本运行并且一旦失败就退出

    (2)如果传入了usage标志就输出使用帮助

    (3)可以的话,从先前的构建中恢复任何已经保存的组件

    (4)将应用程序源安装到默认目录中

    (5)从源代码构建应用程序。在这个例子里,构建即是简单的给app.sh赋予可执行权限这一步

    S2I构建的运行脚本负责运行应用程序。它是镜像默认会去执行的脚本:

    1. #!/bin/bash -e
    2. exec /opt/openshift/src/app.sh

    至此,构建器已经准备就绪,用户可以执行make来构建自己的S2I构建器镜像了。它会创建出一个叫sti-simple-shell的Docker镜像,里面会提供应用程序镜像构建所需的环境,包括之前建出来的软件项目。make的输出结果看上去应该会类似下面这样:

    1. $ make
    2. imiell@osboxes:/space/git/sti-simple-shell$ make
    3. docker build no-cache -t sti-simple-shell .
    4. Sending build context to Docker daemon 153.1 kB
    5. Sending build context to Docker daemon
    6. Step 0 : FROM openshift/base-centos7
    7. —-> f20de2f94385
    8. Step 1 : RUN chown -R default:default /opt/openshift
    9. —-> Running in f25904e8f204
    10. —-> 3fb9a927c2f1
    11. Removing intermediate container f25904e8f204
    12. Step 2 : COPY ./.sti/bin /usr/local/sti
    13. —-> c8a73262914e
    14. Removing intermediate container 93ab040d323e
    15. Step 3 : RUN chmod +x /usr/local/sti/
    16. —-> Running in d71fab9bbae8
    17. —-> 39e81901d87c
    18. Removing intermediate container d71fab9bbae8
    19. Step 4 : USER default
    20. —-> Running in 5d305966309f
    21. —-> ca3f5e3edc32
    22. Removing intermediate container 5d305966309f
    23. Successfully built ca3f5e3edc32

    如果执行docker images,现在应该可以看到一个叫sti-simple-shell的镜像存放在宿主机本地。

    6.构建应用程序镜像

    回想一下图5-1中列出的流程,如今我们已经得到了一次S2I构建所需的3样东西:

    • 源代码;
    • 一个构建器镜像,提供构建和运行源代码的环境;
    • sti程序。

    在这次演练里3样东西都放在同一地方,但是在运行时唯一需要在本地的只是sti程序。构建器镜像可以从注册中心获取,而源代码可以从Git仓库(如GitHub)中提取。

    由于已经准备好了所有3个部分,现在就可以通过sti程序跟踪构建流程了:

    1. $ sti build force-pull=false loglevel=1 \ 1
    2. file:///root/myproject sti-simple-shell final-image-1 (2) 
    3. I0608 13:02:00.727125 00119 sti.go:112] Building final-image-1          
    4. I0608 13:02:00.843933 00119 sti.go:182] Using assemble from image:///usr/local/sti (3)
    5. I0608 13:02:00.843961 00119 sti.go:182] Using run from image:///usr/local/sti
    6. I0608 13:02:00.843976 00119 sti.go:182] Using save-artifacts from image:///
    7. usr/local/sti
    8. I0608 13:02:00.843989 00119 sti.go:120] Clean build will be performed
    9. I0608 13:02:00.844003 00119 sti.go:130] Building final-image-1
    10. I0608 13:02:00.844026 00119 sti.go:330] No .sti/environment provided
    11. (no evironment file found in application sources) 
    12. I0608 13:02:01.178553 00119 sti.go:388]—-> Installing application source
    13. I0608 13:02:01.179582 00119 sti.go:388]—-> Building application from source  (4
    14. I0608 13:02:01.294598 00119 sti.go:216] No .sti/environment provided
    15. (no evironment file found in application sources)
    16. I0608 13:02:01.353449 00119 sti.go:246] Successfully built final-image-1 ) 5

    (1)使用S2I的build子命令来运行构建,禁用镜像默认的强制拉取功能(镜像只存在本地)然后把日志记录提高到一个有用的级别(可以增加数值来获得更多详细信息)

    (2)将构建指向源代码的Git仓库,然后传入此源代码的S2I构建器镜像引用以及预期产出的应用镜像的标签

    (3)相关构建详情的常规调试信息

    (4)相关构建镜像里应用程序源的通用调试信息

    (5)应用程序镜像构建的细节

    在这个例子里,Git仓库是本地存放的(因此前缀是file://),但是也可以通过指向一个在线的仓库的URL(如https://gitserver.example.com/yourusername/yourproject或git@gitserver.example. com:yourusername/yourproject)来引用。

    现在可以运行构建好的镜像,源代码已经被应用到了里面:

    1. $ docker run final-image-1
    2. Hello World
    7.修改及重新构建

    现在既然已经有了一个可工作的案例,就不难发现这一构建方法的设计初衷。试想你是一个新来的开发人员,准备给项目贡献代码。你可以在Git仓库上做修改,然后运行一条简单的命令来重新构建镜像而无须知道任何Docker的细节:

    1. $ cd /root/myproject
    2. $ cat > app.sh <<< echo Hello S2I!’”
    3. $ git commit -am new message
    4. $ sti build force-pull=false file:///root/myproject sti-simple-shell \
    5.  final-image-2

    运行这个镜像会展示在上述代码里设置的新消息:

    1. $ docker run final-image-2
    2. Hello S21!

    本技巧演示的例子虽然简单,但是不难想象这个框架可以怎样适配用户的特定需求。用户最终得到的是一种可以让开发人员推送变更给他们的软件的其他消费者而无须关心Docker镜像产出的细节的手段。

    本技巧可以结合其他技术来改进DevOps流程。举个例子,通过使用Git的post-commit钩子,可以在签入代码的时候自动调用S2I的构建。

    如果用户创建了大量的镜像并且到处传播它们,那么镜像大小的问题很可能会被提上议程。尽管Docker使用的镜像分层技术可以帮助改善这一点,但开发人员在自己的一亩三分地上仍然可能会有这样一堆不易于实际管理的镜像。

    遇到这些情况时,在组织内部推行一些最佳实践相对而言有助于把镜像尽可能往小缩减。在本节中,我们将展示其中的一些技巧,甚至标准的工具镜像可以从96 MB减到只有6.5 MB——在自己内部网络上传播的对象要小得多。

    由于Dockerfile是构建镜像的推荐方式,当提出缩减镜像大小的想法时社区自然会想到它们。这样的结果是形成了一些利用Dockerfile的功能还有解决一些局限性的建议。

    问题

    想要缩减用Dockerfile产出的镜像的大小。

    解决方案

    减少Docker构建时镜像分层的开销。

    讨论

    我们打算从一个相当经典的用来构建OSQuery的Dockerfile开始,它是一款使用SQL接口来报告系统性能的工具,其Dockerfile的内容如代码清单5-6所示。

    代码清单5-6 OSQuery Dockerfile

    1. FROM ubuntu:14.04
    2. RUN apt-get update && apt-get upgrade y 1) 
    3. RUN apt-get install -y git 2)        
    4. RUN apt-get install -y wget        
    5. RUN git clone https://github.com/facebook/osquery.git (3)    
    6. WORKDIR /osquery 4)         
    7. RUN git checkout 1.0.3
    8. RUN ./tools/provision.sh
    9. RUN make
    10. RUN make package          
    11. RUN dpkg -i \
    12. ./build/linux/osquery-0.0.1-trusty.amd64.deb 5) 
    13. CMD [“/usr/bin/osqueryi”] 6

    (1)初始化Ubuntu容器并升级到最新的包

    (2)安装必需的软件包

    (3)签出OSQuery Git仓库

    (4)将OSQuery 1.0.3版本构建成一个deb包

    (5)安装创建的deb包

    (6)将容器设置成默认启动OSQuery工具

    这个镜像的构建给我们提供了一个大小为2.377 GB的镜像——一个相当大的镜像!

    1.使用一个更小的基础镜像

    缩减最终镜像大小的最简单的方法便是从一个更小的基础镜像构建。对于这个例子,我们将把FROM这一行改为基于stackbrew:ubuntu:14.04镜像而非官方的ubuntu:14.04。

    请记住使用一个更小的基础镜像便意味着可能会缺失一些先前安装到大一点儿的那个镜像的软件。这些软件包可能包括sudo、wget等。本技巧中不考虑这一点。

    完成这一步的话可以把镜像的大小缩减大约10%到2.186 GB。

    2.自己事后清理

    可以通过从镜像里删除一些软件包和信息来进一步缩减镜像的大小。如代码清单5-7所示,在CMD指令前面添加这几行到代码清单5-6里将会极大地减少容器里的文件数量。

    代码清单5-7 OSQuery Dockerfile的碎片清理

    1. RUN SUDO_FORCE_REMOVE=yes apt-get purge -y git wget sudo
    2. RUN rm -rf /var/lib/apt/lists/
    3. RUN apt-get autoremove -y
    4. RUN apt-get clean
    5. RUN cd
    6. RUN rm -rf /osquery

    我们的镜像大小现在比最近的一个版本还大一点儿,大约是2.19 GB!由于Docker是分层的,每一条RUN命令都会在最终镜像里创建一个新的写时复制(copy-on-write)分层,即使我们把文件删掉还是会导致镜像大小的增长。这触发了我们下面的改进。

    什么是写时复制 写时复制是一项在处理文件时最小化资源占用的技术。只希望读取文件的进程看到的是来自最上层展示的文件——不同容器里的进程可以看到相同的底层文件,因为它们的镜像共享同一个分层。在大多数系统上这样做可以显著降低所需磁盘空间的使用量。当一个容器想要修改一个文件时,该文件会在它被修改前(否则其他容器也能感知到这一变化)复制到该容器所在的分层,这便是写时复制。

    3.将一系列命令设置为一行

    尽管可以手动将镜像扁平化(见技巧43),但是也可以在Dockerfile里通过将所有命令放到一条RUN指令中以实现相同的效果,如代码清单5-8所示。

    代码清单5-8 带有单条RUN指令的OSQuery Dockerfile

    1. FROM stackbrew/ubuntu:14.04
    2. RUN apt-get update && apt-get upgrade -y && \ 1
    3.   apt-get install -y git wget sudo && \
    4.   git clone https://github.com/facebook/osquery.git && \
    5.   cd /osquery && \
    6.   git checkout 1.0.3 && \
    7.   ./tools/provision.sh && \
    8.   make && \
    9.   make package && \
    10.   dpkg -i ./build/linux/osquery-0.0.1-trusty.amd64.deb && \
    11.   SUDO_FORCE_REMOVE=yes apt-get purge -y git wget sudo && \
    12.   rm -rf /var/lib/apt/lists/ && \
    13.   apt-get autoremove -y && \
    14.   apt-get clean && \
    15.   cd / && \
    16.   rm -rf /osquery
    17. CMD [“/usr/bin/osqueryi”]

    (1)整个安装过程可以缩减到一条RUN指令

    成功了!构建好的镜像报告显示的大小现在变成了1.05 GB。我们已经把镜像大小缩减到了原本大小的一半了。

    4.编写一个脚本来完成安装

    完成所有这一切之后,用户可能会觉得生成的Dockerfile可读性有点儿差。实现相同效果的一种更加友好的方式是,只需要一点小小的开销,将RUN命令放到一个脚本里然后自行复制进去并且执行即可,如代码清单5-9和代码清单5-10所示。

    代码清单5-9 基于一个shell脚本安装的OSQuery Dockerfile

    1. FROM stackbrew/ubuntu:14.04
    2. COPY install.sh /install.sh
    3. RUN /bin/bash /install.sh && rm /install.sh
    4. CMD [“/usr/bin/osqueryi”]

    代码清单5-10 install.sh

    1. #!/bin/bash
    2. set -o errexit 1)      
    3. apt-get update
    4. apt-get upgrade -y
    5. apt-get install -y git wget sudo
    6. git clone https://github.com/facebook/osquery.git
    7. cd /osquery
    8. git checkout 1.0.3
    9. ./tools/provision.sh
    10. make
    11. make package
    12. dpkg -i ./build/linux/osquery-0.0.1-trusty.amd64.deb
    13. SUDO_FORCE_REMOVE=yes apt-get purge -y git wget sudo
    14. rm -rf /var/lib/apt/lists/
    15. apt-get autoremove -y
    16. apt-get clean
    17. cd /
    18. rm -rf /osquery

    (1)将bash脚本配置为只要其中任何命令返回一个非零退出码就抛出错误

    已构建的镜像报告显示的大小没有改变,仍然是1.05 GB。

    解决一个又来一个 由于做出了这些更改,用户也因此失去了许多Dockerfile显而易见的有用功能。举个例子,因为工作变成了一个单条指令,所以不会有构建缓存,也就丧失了构建缓存所带来的省时的好处。同以往一样,这是一个在镜像大小、构建灵活性和构建时间三者之间的权衡。

    利用这样几个简单的技巧,用户可以显著地缩减生成的镜像的大小。尽管如此,故事还没有结束,利用下面更加激进的技巧,用户还可以对镜像进行更加严格的瘦身。

    我们不妨假设用户拿到一个第三方提供的镜像,而他希望让镜像变得更小。最简单的办法便是启动一个可以工作的镜像,然后删除一些不必要的文件。

    经典的配置管理工具往往不会删除东西,除非显式地声明这样做——取而代之的是它们会从一个不工作的状态开始然后往里面添加新的配置和文件。这导致出于一个特定目的制作出的系统千奇百怪,而且可能会与在一台全新的服务器上运行配置管理工具的结果看上去不太一样,尤其是在配置已经演变了一段时间。通过Docker友好的分层及轻量的镜像技术,可以完成这一流程的反向操作并尝试删除一些东西。

    问题

    想要让镜像变得更小。

    解决方案

    删除不必要的软件包和文档文件。

    讨论

    本技巧将会遵循下列步骤来缩减一个镜像的大小:

    (1)运行该镜像;

    (2)进入容器内部;

    (3)删除不必要的文件;

    (4)提交容器作为一个新镜像(见技巧14);

    (5)扁平化该镜像(见技巧43)。

    最后两步在本书前面已经讲过,所以这里仅介绍前面3个步骤。

    为了讲解如何利用这一技巧,我们打算使用在技巧40里创建的镜像,并尝试使这个镜像变得更小。

    先将该镜像作为一个容器运行起来;

    1. docker run -ti name smaller entrypoint /bin/bash \
    2. dockerinpractice/log-cleaner

    因为这是一个基于Debian的镜像,所以用户可以先看看有哪些可能不需要的软件包然后删掉它们。执行dpkg -l | awk ‘{print $2}’用户便能拿到系统上已经安装的软件包的清单。

    之后,用户可以通过执行apt-get purge -y packge_name来清理这些软件包。如果跳出一条吓人的消息警告用户“你要做的操作可能有害”,不妨点击“返回”以继续。

    一旦删掉所有能够安全删除的软件包,紧接着就可以运行如下这些命令来清理apt的缓存:

    1. apt-get autoremove
    2. apt-get clean

    这是一个相对安全地减少镜像里空间占用的办法。

    通过删除文档可以进一步节省大量空间。举个例子,执行rm -rf /usr/share/doc/ /usr/share/man/ /usr/share/info/ 往往可以删除大概永远不会用到的一些大文件。用户还可以通过手动运行rm来删除一些不需要的二进制和类库以进一步缩减镜像的大小。

    多数还会选择的另一个地方便是/var目录,这里面应该会包含一些临时数据,或者一些对正在运行的程序并非必要的数据。

    下面这条命令将会排除所有后缀为.log的文件:

    1. find /var | grep ‘.log$ | xargs rm -v

    借助这个有点儿手工的流程,用户可以把最初的dockerinpractice/log-cleaner镜像轻松减少几十MB,而且如果有动力的话甚至还可以让它变得更小。记住,由于Docker是分层的,用户将需要按照技巧43里介绍的那样把镜像导出并导入。

    相关技巧 技巧53将会展示一种更为有效(但是也颇有风险)的方法,它可以显著地缩减镜像的大小。

    由于Docker的分层技术,其镜像的大小只会在对它进行操作后才会变大。要让缩减的镜像最终定型,就得用技巧43里重点提到的镜像扁平化技术。

    受维护的例子 这个例子中介绍的一系列命令在https://github.com/docker-in-practice/log-cleaner-purged维护,而且可以从dockerinpractice/log-cleaner-purged拉取。

    自Linux诞生起就出现了一些小而可用的操作系统,它们可以嵌到低功耗或者廉价的计算机上。幸运的是,这些项目的努力成果已经被重新用于生产小型Docker镜像,它们可以用于镜像大小非常重要的场合。

    问题

    想要得到一个小而功能俱全的镜像。

    解决方案

    使用一个最小的Linux构建版本,如BusyBox或者Alpine。

    讨论

    这是另外一个领域,其中现有技术的更迭非常迅速。两种流行的选择是BusyBox和Alpine,而每种都有不同的特点。

    如果用户的目标是小而精,那么BusyBox可能会是首选。倘若用户以如下命令启动一个BusyBox镜像,那么可能会发生一些意外情况:

    1. $ docker run -ti busybox /bin/bash
    2. exec: “/bin/bash”: stat /bin/bash: no such file or directory2015/02/23
    3. 09:55:38 Error response from daemon: Cannot start container
    4. 73f45e34145647cd1996ae29d8028e7b06d514d0d32dec9a68ce9428446faa19: exec:
    5. “/bin/bash”: stat /bin/bash: no such file or directory

    BusyBox甚至已经精简到了没有bash的程度!取而代之的是它使用ash,这是一个兼容posix的shell——实际上它是像bash和ksh这样更高级shell的一个受限版本:

    1. $ docker run -ti busybox /bin/ash
    2. /#

    而许多类似这样的决策的结果便是,BusyBox镜像的大小竟然精简到了小于2.5 MB!

    标准实用程序的非GNU版本 BusyBox还有一些其他出人意料的行为。举个例子,该镜像下tar命令的版本将很难从GNU标准的tar包中解压出TAR文件。

    如果用户想要编写一个小脚本而只依赖一些简单工具的话,这样做会很赞,但是如果想要在其他任何必须自行安装它的地方运行,这就不太好了。BusyBox没有自带的包管理。

    其他维护人员已经给BusyBox加上了包管理的功能。举个例子,progrium/busybox可能不是最小的BusyBox容器(它现在小于5 MB),但是它有opkg,这意味着用户可以轻松地安装其他常用软件包,同时将镜像的大小保持为绝对最小。举个例子,如果缺少bash的话,可以像下面这样安装它:

    1. $ docker run -ti progrium/busybox /bin/ash
    2. / # opkg-install bash > /dev/null
    3. / # bash
    4. bash-4.3#

    在提交时,这会生成一个6 MB的镜像。

    有一个不太完善但是很有意思的Docker镜像(可能会取代progrium/busybox)便是gliderlabs/ alpine。它和BusyBox很像,但是有更广泛的软件包,读者可以浏览http://forum.alpinelinux.org/ packages了解更多细节。

    这些软件包均被设计为精简安装。作为一个具体示例,代码清单5-11展示了一个Dockerfile,它产生的镜像大小为三分之一吉字节。

    代码清单5-11 Ubuntu加mysql-client

    1. FROM ubuntu:14.04
    2. RUN apt-get update -q \
    3. && DEBIAN_FRONTEND=noninteractive apt-get install -qy mysql-client \
    4. && apt-get clean && rm -rf /var/lib/apt
    5. ENTRYPOINT [“mysql”]

    使用DEBIAN_FRONTEND=noninteractive避免交互 在apt-get install之前加上DEBIAN_FRONTEND = noninteractive可以确保在安装时不会在安装过程中提示任何输入。由于用户不能在运行命令时轻松地响应问题,因此这一点往往在Dockerfile里非常有用。

    对比之下,这次会产出一个略大于16 MB的镜像:

    1. FROM gliderlabs/alpine:3.1
    2. RUN apk-install mysql-client
    3. ENTRYPOINT [“mysql”]

    尽管可以通过删除冗余文件的方式把工作中的容器精简下来,但这里还有另外一个选择——编译没有依赖的最小二进制。

    这样做从根本上简化了配置管理的任务——如果只有一个文件要部署,而且没有依赖包的话,大量的配置管理工具就会变得多余。

    问题

    想要构建一个没有外部依赖的二进制Docker镜像。

    解决方案

    构建一个静态链接的二进制。

    讨论

    为了演示如何使用它,我们首先创建一个小的带有一个C语言小程序的“Hello World”镜像,然后我们进一步展示,针对一个更有价值的应用程序如何完成等价的事情。

    1.一个最小的Hello World二进制

    先创建一个新目录,然后创建一个Dockerfile,如代码清单5-12所示。

    代码清单5-12 Hello Dockerfile

    1. FROM gcc 1)       
    2. RUN echo int main() { puts(“Hello world!”); }’ > hi.c 2
    3. RUN gcc -static hi.c -w -o hi 3

    (1)这个gcc镜像是一个专为编译而设计的镜像

    (2)创建一个简单的单行C程序

    (3)使用-static标志编译这一程序,然后使用-w禁止警告

    上述Dockerfile编译了一个简单的没有任何依赖的“Hello world”程序。用户现在可以构建它,并从容器中提取该二进制文件,如代码清单5-13所示。

    代码清单5-13 从镜像里提取二进制文件

    1. $ docker build -t hello_build . 1)   
    2. $ docker run name hello hello_build /bin/true 2
    3. $ docker cp hello:/hi new_folder 3
    4. $ docker rm hello 4)         
    5. hello
    6. $ docker rmi hello_build     
    7. Deleted: 6afcbf3a650d9d3a67c8d67c05a383e7602baecc9986854ef3e5b9c0069ae9f2

    (1)构建包含静态链接的“hi”二进制程序的镜像

    (2)使用一条小命令运行镜像以便复制出二进制文件

    (3)使用docker cp命令将“hi”二进制程序复制到new_folder里

    (4)清理:不再需要这些东西

    至此,用户在一个全新的目录里得到了一个静态地构建好的二进制程序。通过下面这条命令切换到该目录:

    1. $ cd new_folder

    现在创建其他的Dockerfile,如代码清单5-14所示。

    代码清单5-14 最小Hello Dockerfile

    1. FROM scratch 1)  
    2. ADD hi /hi 2)  
    3. CMD [“/hi”] 3

    (1)使用零字节的空白镜像

    (2)把“hi”二进制程序添加到镜像

    (3)将镜像设置为默认执行“hi”二进制程序

    如代码清单5-15中展示的那样构建并运行它。

    代码清单5-15 创建最小容器

    1. $ docker build -t hello_world .
    2. Sending build context to Docker daemon 931.3 kB
    3. Sending build context to Docker daemon
    4. Step 0 : FROM scratch
    5. —->
    6. Step 1 : ADD hi /hi
    7. —-> 2fe834f724f8
    8. Removing intermediate container 01f73ea277fb
    9. Step 2 : ENTRYPOINT /hi
    10. —-> Running in 045e32673c7f
    11. —-> 5f8802ae5443
    12. Removing intermediate container 045e32673c7f
    13. Successfully built 5f8802ae5443
    14. $ docker run hello_world
    15. Hello world!
    16. $ docker images | grep hello_world
    17. hello_world   latest  5f8802ae5443  24 seconds ago 928.3 kB

    该镜像构建出来,运行,而大小不超过1 MB!

    2.最小的Go Web服务器镜像

    这是一个相对简单的例子,但是相同的原理可以推广到用Go语言来构建的程序。Go语言的一个很吸引人的特性是,构建这种静态二进制文件相对比较简单。

    为了演示这种能力,我们创建了一个用Go语言实现的简单Web服务器,它的全部代码都放在https://github.com/docker-in-practice/go-web-server。

    构建这一简单Web服务器的Dockerfile,如代码清单5-16所示。

    代码清单5-16 静态编译一个Go Web服务器的Dockerfile

    1. FROM golang:1.4.2 1
    2. RUN CGO_ENABLED=0 go get \ 2)      
    3. -a -ldflags ‘-s -installsuffix cgo \ 3
    4. github.com/docker-in-practice/go-web-server 4)   
    5. CMD [“cat”,”/go/bin/go-web-server”] 5

    (1)这一次构建已经验证过是可以工作在这一版本的golang镜像;如果构建失败,则可能是这一版本已经不再可用

    (2)go get命令会从提供的URL获取源代码,然后在本地编译它。将CGO_ENABLED环境变量设置为0是为了防止交叉编译

    (3)为Go编译器设置的许多杂七杂八的标志是为了保证静态编译并缩减编译后的文件大小

    (4)Go Web服务器的源代码仓库

    (5)设置生成镜像的默认命令为输出该可执行文件

    如果将此Dockerfile保存到一个空目录然后用它做构建的话,将可以得到一个包含该程序的镜像。因为已经将该镜像的默认命令指定为输出可执行文件的内容,所以现在只需要运行该镜像,然后把输出发送到宿主机上的一个文件,如代码清单5-17所示。

    代码清单5-17 从镜像里获取Go Web服务器

    1. $ docker build -t go-web-server . 1
    2. $ mkdir -p go-web-server && cd go-web-server 2
    3. $ docker run go-web-server > go-web-server 3)  
    4. $ chmod +x go-web-server 4

    (1)构建并给镜像打标签

    (2)创建并切换到一个全新的目录来存放二进制文件

    (3)运行该镜像然后将二进制文件的输出重定向到一个文件

    (4)给二进制文件赋予可执行权限

    现在,和“hi”程序一样,用户得到一个没有类库依赖也不需要访问文件系统的二进制文件。如此一来,正如前面所讲,我们就可以从零字节的空白镜像开始创建一个Dockerfile,然后把二进制文件添加到里面:

    1. FROM scratch
    2. ADD go-web-server /go-web-server 1) 
    3. ENTRYPOINT [“/go-web-server”] 2

    (1)将静态二进制文件添加到镜像

    (2)将镜像设置为默认运行此程序

    现在可以构建它并且运行这一镜像。生成的镜像的大小略大于4 MB:

    1. $ docker build -t go-web-server .
    2. $ docker images | grep go-web-server
    3. go-web-server  latest  de1187ee87f3 3 seconds ago  4.156 MB
    4. $ docker run -p 8080:8080 go-web-server -port 8080

    可以打开http://localhost:8080访问它。如果端口事先已经被占用,不妨自己选一个端口替换上述代码里的两个8080。

    3.Docker是多余的吗

    如果可以将应用程序捆绑到一个二进制文件的话,为何还要用Docker呢?用户可以把二进制文件移走,运行多个副本,等等。

    用户愿意的话,当然可以这么做,但是这样会失去下面这些特性:

    • Docker生态系统里所有的容器管理工具;
    • Docker镜像里的元数据,它记录了重要的应用信息,如端口、卷、标签等;
    • Docker的隔离性所带来的可运维能力。

    现在我们将使用一个小巧的工具来进一步给容器瘦身,它会告诉我们当运行一个容器时有哪些文件会被引用。

    这可以称作是一项“核武器”,因为在生产中实施的话可能是相当危险的。但是,它算是一个有助于了解系统的指导手段,即使不遵循下面介绍的去实际使用它也没关系——要知道配置管理的一个关键部分便是理解应用程序正常运转所需的条件。

    问题

    想要将容器里的文件和权限集尽可能缩减到最小。

    解决方案

    使用inotify-tools来确定应用需要哪些文件,然后删除所有其他文件。

    讨论

    从整体上来说,用户需要知道当其在一个容器里执行一条命令时它会访问哪些文件。如果用户将容器文件系统上所有其他文件都删掉的话,理论上来说依旧可以拥有一切运行时所需的东西。

    在这次演示中,将会用到技巧50里介绍过的log-cleaner-purged镜像。用户需要安装好inotify-tools,然后再执行inotifywait得到一个访问了哪些文件的报告。随后运行模拟该镜像的入口点的程序(log_clean脚本)。紧接着,用户可以依据生成的文件报告,删除任何没有访问到的文件:

    1. [host]$ docker run -ti entrypoint /bin/bash \ 1
    2. name reduce dockerinpractice/log-cleaner-purged 2
    3. $ apt-get update && apt-get install -y inotify-tools 3
    4. $ inotifywait -r -d -o /tmp/inotifywaitout.txt \ 4
    5. /bin /etc /lib /sbin /var 5
    6. inotifywait[115]: Setting up watches. Beware: since -r was given, this
    7. may take a while!
    8. inotifywait[115]: Watches established.        
    9. $ inotifywait -r -d -o /tmp/inotifywaitout.txt /usr/bin /usr/games \ 6
    10. /usr/include /usr/lib /usr/local /usr/sbin /usr/share /usr/src
    11. inotifywait[118]: Setting up watches. Beware: since -r was given, this
    12. may take a while!
    13. inotifywait[118]: Watches established.  
    14. $ sleep 5 7
    15. $ cp /usr/bin/clean_log /tmp/clean_log 8
    16. $ rm /tmp/clean_log               
    17. $ bash                       
    18. $ echo Cleaning logs over 0 days old
    19. $ find /log_dir -ctime 0 -name log -exec rm {} \; 9)    
    20. $ awk ‘{print $1$3}’ /tmp/inotifywaitout.txt | sort -u > \   
    21. /tmp/inotify.txt 10
    22. $ comm -2 -3 \ 11)  
    23. <(find /bin /etc /lib /sbin /var /usr -type f | sort) \
    24. <(cat /tmp/inotify.txt) > /tmp/candidates.txt
    25. $ cat /tmp/candidates.txt | xargs rm 12)     
    26. $ exit 13
    27. $ exit

    (1)安装inotify-tools包

    (2)覆盖此镜像默认的入口程序

    (3)给容器起一个名字,后面可以用它来引用该容器

    (4)以递归(-r)和守护进程(-d)模式运行inotifywait,获取一个已访问文件的清单并写到outfile(以-o标志指定的)里

    (5)指定感兴趣的需要关注的文件夹。注意不要监听/tmp,因为/tmp/inotifywaitout. txt文件如果自己监听自己的话可能会造成一个死循环

    (6)对/usr文件夹上的子文件夹再次执行inotifywait。由于/usr文件夹里有太多文件需要inotifywait来处理,因此用户需要单独一个个地去指定

    (7)Sleep 会给予inotifywait 一个合理的等待启动的时间

    (8)记得访问一个要用到的脚本文件。还有,要确保有执行rm命令的权限

    (9)像脚本里做的那样,启动一个bash shell,然后运行脚本里本来要执行的一些命令

    (10)利用awk工具从inotifywait的日志输出里生成一个文件名清单,然后将它去重并排序

    (11)使用comm工具输出一个文件系统上未访问的文件清单

    (12)删除所有未访问的文件

    (13)退出之前打开的bash shell,随后再退出容器本身

    现在已经完成:

    • 给一些文件设置监听以查看哪些文件是被访问的;
    • 执行所有的命令来模拟脚本的运行;
    • 执行一些命令确保用户有权限访问后面肯定要用到的脚本和rm实用工具;
    • 获取一个运行期间未被访问的所有文件的清单;
    • 删除所有未被访问的文件。

    现在,可以将此容器扁平化(见技巧43),创建出一个新镜像,然后测试它是否仍然能够正常工作:

    1. $ ID=$(docker export reduce | docker import -) 1
    2. $ docker tag $ID smaller 2
    3. $ docker images | grep smaller
    4. smaller latest 2af3bde3836a 18 minutes ago 6.378 MB 3)  
    5. $ mkdir -p /tmp/tmp 4)              
    6. $ touch /tmp/tmp/a.log 5)           
    7. $ docker run -v /tmp/tmp:/log_dir smaller \
    8. /usr/bin/clean_log 0
    9. Cleaning logs over 0 days old
    10. $ ls /tmp/tmp/a.log
    11. ls: cannot access /tmp/tmp/a.log: No such file or directory

    (1)将镜像扁平化然后把镜像ID放到环境变量ID里

    (2)给新的扁平镜像打上smaller的标签

    (3)现在该镜像甚至比之前大小的10%还小

    (4)在测试目录上运行新创建的镜像,并检查创建的文件是否已经被删除

    (5)为了测试创建一个新的文件夹和文件,模拟一个日志目录

    我们将此镜像的大小从96 MB缩减到了大约6.5 MB,而它似乎仍然可以正常工作。相当节俭!

    有风险! 本技巧就像CPU超频一样,并不是一个无关紧要的优化。这个特定案例能够很正常地运转,是因为它是一个运行范围相当有限的应用程序,但是用户的一些核心关键业务应用程序可能是更复杂的,而且在如何访问文件方面可能是更加动态的。用户可以轻易删除一个在运行时未访问的文件,但是该文件可能会在某些其他场景下需要用到。

    如果有点儿担心删掉的这些文件后面可能会用到而导致镜像损坏的话,可以用/tmp/candidates.txt文件收录未触及的最大文件的清单,如下所示:

    1. cat /tmp/candidates.txt | xargs wc -c | sort -n | tail

    然后可以删掉那些确定应用程序将来不会用到的大一点儿的文件。这也是一场大的胜利!

    尽管本节是关于如何保持镜像小的,但值得铭记的是小也不一定就是更好的。正如我们接下来将讨论的那样,一个相对较大的单体镜像可以比一个小镜像更加高效。

    问题

    想要降低由于Docker镜像导致的磁盘空间占用和网络带宽。

    解决方案

    为组织内部创建一个统一的、较大的、单体的基础镜像。

    讨论

    这是一个两难的取舍,但是使用一个大的单体镜像可以帮用户节省磁盘空间和网络带宽。

    回想一下,Docker在容器正在运行时使用的是写时复制机制。这意味着用户可以运行数百个Ubuntu容器,而在每个容器启动后只需要占用少量额外的磁盘空间。

    如图5-2所示,如果用户在Docker服务器上运行了大量不同的较小的镜像的话,那么使用的磁盘空间甚至可能会比运行一个大一些的一切所需都包揽到其中的单体镜像还要多。

    5

    图5-2 许多小的基础镜像与较少的大的基础镜像的对比

    读者可能会想起共享库的原理。一个共享库可以一次性被多个应用程序加载,这减少了要运行这些程序所需的磁盘空间和内存的使用量。同理,一个组织内共享的基础镜像可以节省空间,因为它只需要被下载一次,而且应该囊括了所需的一切。之前多个镜像里需要用到的程序和库现在只需要引用一次。

    此外,这样做的另外一个好处是可以有一个跨团队共享的单体的、集中管理的镜像。该镜像的维护可以是集中式的,一些改进也是共享的,并且构建中遇到的问题只需要解决一次。

    要采用本技巧,需要注意下列事项。

    • 基础镜像首先应该是可靠的。如果它的行为不一致,应当避免使用。
    • 对基础镜像的变更应该在某处可以可视化地跟踪到,以便用户可以自行调试。
    • 在更新香草(vanilla)镜像时一些回归测试是至关重要的,这样可以减少麻烦。
    • 在添加内容到基础镜像时要谨慎——一旦添加到了基础镜像,它便很难删除,而且镜像会膨胀得很快。

    我们在自己600强的开发公司里使用了本技巧,产生的效果非常好。每月构建的核心应用会被打包到一个大的镜像并发布到内部的Docker注册中心。默认情况下,团队将会在所谓的“香草”企业镜像上构建应用,有必要的话再在上面创建定制镜像分层。

    本章展示了如何管理Docker镜像的配置,这是任何Docker工作流都必须关注的一个重点。我们一起了解了用户可以如何在Docker官方推荐的路线中结合现有流程,并且介绍了一些构建镜像时的最佳实践。

    我们就下面几个问题讨论了“如何做”:

    • 如果觉得受限的话,可以扩展Dockerfile的灵活性;
    • 可以通过扁平化镜像的方式删除底层镜像分层里的私密文件;
    • Dockerfile和像Chef这样更加传统的配置管理工具并不是互斥的;
    • 用户应该努力让自己的镜像尽可能小。

    至此,读者已经通过一些手段得到了自己的Docker镜像,是时候考虑该如何和其他人分享这些镜像,为促进持续交付和持续集成打下基础。


    [1]  Ddocker从1.9版本开始已经引入ARG指令支持传入变量,见https://docs.docker.com/engine/reference/ builder/#arg。——译者注