第4章 Docker日常

    本章主要内容

    • 使用和管理Docker卷以实现共享数据的持久化
    • 学习第一个Docker模式:数据及开发工具容器
    • 在Docker里使用GUI应用
    • 操纵Docker构建缓存以实现快速可靠的构建
    • 让Docker镜像系谱图可视化
    • 从宿主机上直接让容器执行命令

    当使用Docker开发软件时,读者会发现不断冒出来各种各样的需求。用户可能想法设法地要在一个容器里运行GUI应用,陷入对Dockerfile构建缓存的困惑,想要在使用的同时直接操纵容器,好奇镜像之间的系谱关系,想要从一个外部源引用数据,等等。

    本章将带读者领略一些技巧,展示该怎样处理上述这些问题以及可能出现的其他问题。把它看成是Docker工具箱吧!

    容器是一个强大的概念,但是有时候并不是所有想访问的事物都能立马被封装成容器。用户可能有一个存储在大的集群上的相关Oracle数据库,想要连接它做一些测试。又或者,用户可能有一台遗留的大型服务器,而它上面现有配置好的二进制程序很难重现。

    刚开始使用Docker时,用户想要访问的大部分事物可能会是容器外部的数据和程序。我们将和读者一起从直接在宿主机上挂载文件转到更为复杂的容器模式:数据容器和开发工具容器。我们还将展示一些实用的技巧,例如,只需要一个SSH连接便能跨网络进行远程挂载,以及通过BitTorrent协议与其他用户分享数据。

    卷是Docker的一个核心部分,有关外部数据引用的问题也是Docker生态系统中另一个快速变化的领域。

    容器的大部分力量源自它们能够尽可能多地封装环境的文件系统的状态,这一点的确很有用处。

    然而有时候用户并不想把文件放到容器里,而是想要在容器之间共享或者单独管理一些大文件。一个经典的例子便是想要容器访问一个大型的中央式数据库,但是又希望其他的(也许更传统些的)客户端也能和新容器一样访问它。

    解决方案便是,一种Docker用来管理容器生命周期外的文件的机制。虽然这有悖于容器“部署在任何地方”的原则(例如,用户将无法在没有兼容数据库用来挂载的地方部署有数据库依赖的容器),但在实际的Docker使用中这仍然是一个很有用的功能。

    问题

    想要在容器里访问宿主机上的文件。

    解决方案

    使用Docker的卷标志,在容器里访问宿主机上的文件。

    讨论

    图4-1演示了使用卷标志和宿主机上的文件系统交互。

    4

    图4-1 容器里的卷

    下面的命令展示宿主机上的/var/db/tables目录被挂载到了/var/data1,并且在图4-1里启动容器时会被执行:

    1. $ docker run -v /var/db/tables:/var/data1 -it debian bash

    -v标志(—volume的简写)表示为容器指定一个外部的卷。随后的参数以冒号分隔两个目录的形式给出了卷的格式,告知Docker将外部的/var/db/tables目录映射到容器里的/var/data1目录。如果外部目录和容器目录不存在均会被创建。

    对已经存在的目录建立映射要小心。即使镜像中已经存在需要映射的目录,容器的目录也会被建立映射。这意味着在容器里映射的目录的原内容将会消失。如果试图映射一个关键目录,就会发生有趣的事情!例如,尝试挂载一个空目录到/bin

    还要注意的是,卷在Dockerfile里被设定为不是持久化的。如果添加了一个卷,然后在一个Dockefile里对该目录做了一些更改,这些变动将不会被持久化到生成的目标镜像。

    SELinux问题? 如果宿主机运行SELinux,可能会遇到一些问题。如果SELinux的策略是enforced,容器可能无法写入/var/db/tables目录。用户将会看到一个“permission denied”错误。如果想解决这个问题,就需要联系系统管理员(如果有的话)或者关掉SELinux(仅用于开发目的)。可以查看技巧101来了解关于SELinux的更多内容。

    在一个团队里试用Docker时,开发人员可能希望能够在团队成员之间共享大量的数据,但是他可能又没有办法为共享服务器申请到足够容量的资源。对此最懒的办法便是在需要它们的时候从其他团队成员那里复制最新的文件——这对规模更大的团队而言很快便会失去控制。

    解决办法便是用一个去中心化的工具来共享文件——无须专门的资源。

    问题

    想要在互联网上跨宿主机共享卷。

    解决方案

    使用BitTorrent Sync镜像来共享卷。

    讨论

    图4-2展示了达到这一目标所需的设置。

    最终结果便是无须任何复杂配置便可以在互联网上方便地同步卷(/data)。

    4

    图4-2 使用BitTorrent Sync

    在主服务器上,运行如下命令来配置第一台宿主机上的容器:

    1. [host1]$ docker run -d -p 8888:8888 -p 55555:55555 \ 1
    2. name btsync ctlc/btsync
    3. $ docker logs btsync 2
    4. Starting btsync with secret: \
    5. ALSVEUABQQ5ILRS2OQJKAOKCU5SIIP6A3 3
    6. By using this application, you agree to our Privacy Policy and Terms.
    7. http://www.bittorrent.com/legal/privacy
    8. http://www.bittorrent.com/legal/terms-of-use
    9. total physical memory 536870912 max disk cache 2097152
    10. Using IP address 172.17.4.121
    11. [host1]$ docker run -i -t volumes-from btsync \ 4
    12. ubuntu /bin/bash
    13. $ touch /data/sharedfromserverone 5
    14. $ ls /data
    15. shared_from_server_one

    (1)运行已发布的ctlc/btsync镜像作为一个守护容器,称为btsync,然后开放必要的端口

    (2)获取btsync容器的输出,以便记录键的内容

    (3)记下这个键——运行该容器的时候它可能会有所不同

    (4)启动一个交互容器,然后挂载上btsync服务器的卷

    (5)添加一个文件到/data卷

    在次服务器上,开启一个终端并运行这些命令来同步卷:

    1. [host2]$ docker run -d name btsync-client -p 8888:8888 \
    2. -p 55555:55555 \
    3. ctlc/btsync ALSVEUABQQ5ILRS2OQJKAOKCU5SIIP6A3 1
    4. [host2]$ docker run -i -t volumes-from btsync-client \
    5. ubuntu bash 2
    6. $ ls /data                 
    7. shared_from_server_one 3
    8. $ touch /data/shared_from_server_two 4)  
    9. $ ls /data
    10. shared_from_server_one shared_from_server_two

    (1)启动一个btsync客户端容器作为守护进程,并且传递运行在host1上的守护进程生成的键

    (2)启动一个交互容器,通过客户端守护进程挂载卷

    (3)在host1上创建的该文件已经被传送到了host2

    (4)在host2上创建第二个文件

    回到host1上运行的容器,应该能看到该文件正如第一个文件那样在宿主机之间被同步成完全一样的内容:

    1. [host1]$ ls /data
    2. shared_from_server_one shared_from_server_two

    这些文件的同步没有时序保证,因此可能不得不等待数据同步完成,尤其是在文件较大的情况下。

    不保证百分百完美 由于数据是在互联网上传输的,而且是由一个没法掌控的协议来处理的,因此如果有任何安全性、扩展性或者性能要求的话,最好不要依靠它。

    在一个容器里实验时用户会认识到完成的时候可以擦掉所有痕迹,这是一个很开放的体验。但是这样做也会失去一些便利性。一个我们已经经历过很多次的痛点便是不能再重新调用我们之前在容器里运行的一系列命令。

    问题

    想要将容器的bash历史与宿主机上的命令历史共享。

    解决方案

    docker run命令起一个别名来共享容器与宿主机的bash历史。

    讨论

    为了理解这个问题,我们打算展示一个简单的场景,在该场景中没有历史记录的话会很不方便。

    试想一下用户在Docker容器里正在做一些实验,并且工作内容是一些有趣而又可以重复的事情。这里我们将使用一个简单的echo命令,但是它可能会是一长串复杂拼接的程序,最终生成一个有用的输出:

    1. $ docker run -ti rm ubuntu /bin/bash
    2. $ echo my amazing command
    3. $ exit

    在一段时间后,用户想要重新调用早先运行过的那个难以置信的echo命令。但是,他不能再重新调用它了,而且也失去了在屏幕上可以切换过去的终端会话。出于习惯,他尝试在宿主机上翻看bash历史:

    1. $ history | grep amazing

    什么都没返回,因为bash历史被保存在了如今已经删除的容器里,而不是正在使用的宿主机上。为了在宿主机上共享bash历史,可以在运行Docker镜像时挂载一个卷。下面是一个例子:

    1. $ docker run -e HIST_FILE=/root/.bash_history \ (1)
    2. -v=$HOME/.bash_history:/root/.bash_history \ (2)
    3. -ti ubuntu /bin/bash

    (1)设置bash拾取的环境变量。这可以确保所用的bash历史文件就是挂载的那个文件

    (2)将容器里root目录下的bash历史文件映射到宿主机上

    分离容器的bash历史文件 用户可能想要将容器的bash历史和宿主机分离开。要达成这一点的话,一个办法便是改变前面-v参数的第一部分的值。

    每次都要敲键盘还是挺烦人的,因此为了让它对用户更加友好,可以将这个别名放到~/.bashrc文件里:

    1. $ alias dockbash=’docker run -e HIST_FILE=/root/.bash_history \
    2. -v=$HOME/.bash_history:/root/.bash_history

    这仍然不是很平滑,如果想执行docker run命令,必须得记得敲dockbash。为了追求更完美的体验,可以将这些写到~/.bashrc文件里:

    1. function basher() { 1
    2.  if [[ $1 = run ]] 2
    3.  then
    4.   shift 3
    5.   /usr/bin/docker run \ 4)  
    6.    -e HIST_FILE=/root/.bash_history \
    7.    -v $HOME/.bash_history:/root/.bash_history $@ 5
    8.  else
    9.   /usr/bin/docker $@ 6
    10.  fi
    11. }
    12. alias docker=basher 7

    (1)创建一个叫basher的bash函数来处理docker命令

    (2)确定basher/ docker的第一个参数是否是’run’。如果是 ……

    (3)……删除传入的一系列参数中的一个

    (4)运行之前运行的docker run命令,通过指定Docker命令的绝对路径来避免与接下来的docker别名混淆。在实施这一方案前需要先通过运行which docker命令找出宿主机上实际正在运行的Docker的运行时位置

    (5)在run的后面传入实际参数给Docker运行时

    (6)以完整原始传入的参数来运行docker命令

    (7)给在命令行上调用的docker命令起一个别名映射到创建的basher函数。这可以确保调用docker的操作在bash从path里找到docker二进制之前被捕获

    如今在下一次打开bash shell,运行任何docker run命令时,在该容器内运行的命令都将会被添加到宿主机的bash历史。一定要确保Docker的路径是正确的。例如,它的路径可能是/bin/docker。

    别忘了注销宿主机的 bash 会话 为了让历史文件得到更新,将需要注销宿主机上的原始bash会话。这得归咎于bash的微妙机制以及它更新保存在内存里的bash历史的方式。如有疑问,不妨先退出所有已知的bash会话,然后再启动一个新的以尽量确保bash历史是最新的。

    如果在一台宿主机上大量用到卷,容器启动的管理可能会变得很麻烦。用户可能也希望用Docke专门管理数据,而不是通过宿主机进行访问。更干净地管理这些东西的一种办法便是使用纯数据容器(data-only container)的设计模式。

    问题

    想要在容器里使用一个外部卷,但是只想让Docker访问这些文件。

    解决方案

    启动一个数据容器,然后在运行其他容器时使用—volumes-from标志。

    讨论

    图4-3展示了数据容器模式的结构,并且解读了它的工作原理。要注意的关键一点是,在第二台宿主机里,容器并不需要知道数据位于磁盘的哪个位置,它们只要了解数据容器的名字,一切便准备就绪。这样做可以使容器的操作更加具有可移植性。

    与直接映射宿主机目录的方式相比,这种方法的另一个好处是这些文件的访问是由Docker管理的,这也就意味着不太可能出现非Docker进程影响其内容的情况。

    纯数据容器不一定需要运行 一个常常让人困惑的问题便是纯数据容器是否需要运行。不需要!它只需要存在,在宿主机上运行过并且没有被删除。

    让我们通过一个简单的例子直观地展示一下该如何使用这一技巧。首先,运行一个数据容器:

    1. $ docker run -v /shared-data name dc busybox \
    2.  touch /shared-data/somefile

    -v参数并没有将卷映射到一个宿主机目录,因此它将会在这个容器的管辖范围内创建一个目录。这个目录通过touch填充了一个文件,然后容器立刻退出了——一个数据容器被使用的时候并不需要处于运行状态。我们使用了一个小而实用的busybox镜像来减少我们的数据容器所需的额外成本。

    4

    图4-3 数据容器模式

    然后便可以运行其他容器来访问刚创建的文件了:

    1. docker run -t -i volumes-from dc busybox /bin/sh
    2. / # ls /shared-data
    3. somefile

    —volumes-from标志允许通过挂载它们到当前容器的形式来引用数据容器里的文件——只需要给传入卷定义的ID即可。busybox镜像里没有bash,因此必须启动一个简化版shell来确认dc容器里的/shared-data目录对用户而言是的确可用的。

    用户可以启动任意数量的容器,都从指定的数据容器的卷里读和写。

    卷会持久化! 需要明白的重要一点是,使用这种模式会造成大量的磁盘消耗,这可能会导致调试变得相当困难。由于Docker在纯数据容器里管理卷,而且不会在最后一个引用它的容器已经退出的前提下删除该卷,因此,任何在该卷上的数据均会被保留下来。这是为了防止意外的数据丢失。有关这方面该如何管理的建议,见技巧35。

    使用这种模式的话就无须使用卷——读者可能会发现这个方案比直接挂载一个宿主机目录执行起来要更困难些。但是,如果想要将管理数据的职责完全委派给Docker进行单点管理而不受其他宿主机进程干扰的话,那么数据容器会满足这一需求。

    文件路径之争 如果应用程序是从多个容器写日志到同一数据容器的话,很重要的一点便是得确保每个容器日志文件写入的是一个唯一的文件路径。如果无法确保这一点,不同的容器便有可能覆盖或者截断该文件,从而造成数据的丢失,或者可能写入的数据是交错混杂的,这就很难解析文件中的内容。类似地,如果对数据容器调用—volumes-from,就是允许该容器潜在地覆盖自己的目录,因此也要小心这里的命名冲突。

    前面我们讨论了该怎样挂载本地文件,但是很快又出现如何挂载远程文件系统的问题。例如,用户可能会打算在一台远程服务器上共享一个引用的数据库,然后将它当成是本地文件系统来使用。

    虽然从理论上来说可以在宿主机系统及服务器上配置NFS,然后通过挂载该目录来访问远程的文件系统,但是对于大多数用户而言这里有一种更为快捷和简单的方式,无须在服务器上做任何配置(只要有SSH访问权限)。

    需要root权限 用户需要有root权限才能使用这一技巧,而且需要安装FUSE(Linux的用户空间级的文件系统内核模块)。可以通过在一个终端里运行ls /dev/fuse查看文件是否存在来确认当前系统是否支持。

    问题

    想要挂载一个远程文件系统而无须任何服务器端的配置。

    解决方案

    使用SSHFS来挂载远程文件系统。

    讨论

    本技巧使用FUSE内核模块通过SSH提供一个标准的文件系统接口,后台的所有通信都是通过SSH完成的。SSHFS后台还提供了各种功能(如预读取远程文件),从而使用户产生一种文件就在本地的错觉。结果便是用户一旦登录到远程服务器,就可以看到上面的文件,就像这些文件在本地一样。图4-4帮助诠释了这一点。

    4

    图4-4 通过SSHFS挂载一个远程文件系统

    变更不会持久化到容器 虽然这一技巧没有用到Docker卷功能,并且这些文件在文件系统上也是可见的,但是这一技巧并不会提供任何容器级别的持久化。任何变更都将只作用到远程服务器的文件系统。

    用户可以从运行如下命令开始,命令内容可以根据环境做相应调整。

    第一步便是在宿主机上启动一个容器并附加—privileged

    1. $ docker run -t -i privileged debian /bin/bash

    然后在它启动了之后,在容器里运行apt-get update && apt-get install sshfs来安装SSHFS。

    在SSHFS安装成功后,按照如下步骤登录到远程宿主机:

    1. $ LOCALPATH=/path/to/local/directory 1
    2. $ mkdir $LOCALPATH 2
    3. $ sshfs user@host:/path/to/remote/directory $LOCALPATH 3

    (1)选择一个远程位置对应的挂载目录

    (2)创建本地挂载目录

    (3)将这里的对应值替换成远程宿主机用户名,远程宿主机的地址以及远程路径

    现在就可以在刚刚创建的那个文件夹里看到远程服务器对应路径下的内容了。

    通过nonempty选项挂载已经存在内容的目录 挂载到一个新创建的目录是最简单的,但是如果使用-o nonempty选项的话也可以挂载一个已经存在一些文件的目录。可以查阅SSHFS帮助手册来了解更多细节。

    要干净地卸载这些文件的话,可以按照如下方式使用fusermount,根据需要将其替换成对应的路径:

    1. fusermount -u /path/to/local/directory

    这是一种很不错的方法,以最小的成本在容器(和标准的Linux机器)里完成远程挂载。

    在一个大型企业里,它很有可能有一些已经在使用的NFS共享目录——NFS是一种经过验证的方案,用于从一个中央位置提取文件。对Docker而言,能够访问这些共享文件是一件非常重要的事情。

    Docker并没有原生支持NFS,并且在每个容器上安装一个NFS客户端来挂载远程文件夹也不见得是一个最佳实践。相反,推荐的方案是设置一个容器充当从NFS到一个更为Docker友好的概念——卷的中转站。

    问题

    想要通过NFS无缝访问远程文件系统。

    解决方案

    使用一个基础设施数据容器来中转访问。

    讨论

    这一技巧是在技巧22的基础上构建的。图4-5从概念层面展示了其理念。

    4

    图4-5 一个用作NFS访问中转的基础设施容器

    NFS服务器将一个内部目录公开为/export文件夹,它会被绑定挂载到NFS服务器宿主机上。Docker宿主机随后会使用NFS协议将该文件夹挂载到它的/mnt文件夹,然后再创建一个所谓的基础设施容器来绑定挂载的文件夹。

    乍看上去这样做好像有一点儿过度设计,但是这样做的好处是,对Docker容器而言它提供了一个中间层:它们需要做的就是从一个预先约定好的基础设施容器挂载卷,然后由基础设施容器来处理内部设备的管道、可用性、网络等。

    如何深入了解NFS超出了本书的讨论范畴。在这一技巧中,我们将通过一系列步骤在单台宿主机上配置这样的一个共享,NFS服务器的组件作为Docker容器运行在同一台宿主机上。该项实验已经在Ubuntu 14.04上测试通过。

    假设用户想要共享宿主机上的/opt/test/db文件夹的内容,它里面有一个mybigdb.db的文件。

    以root身份安装一个NFS服务器,然后创建一个开放权限的export目录:

    1. # apt-get install nfs-kernel-server
    2. # mkdir /export
    3. # chmod 777 /export

    绑定挂载该db目录到export目录:

    1. # mount —bind /opt/test/db /export

    现在应该可以在/export里看到/opt/test/db目录下的内容了。

    持久化该绑定挂载 如果想要在下次重启的时候持久化这一操作,需要将opt/test/db/export none bind 0 0这一行加到/etc/fstab文件中。

    现在添加这一行内容到/etc/exports文件中:

    1. /export    127.0.0.1(ro,fsid=0,insecure,no_subtree_check,async)

    针对这个概念性验证示例,我们在127.0.0.1上做了本地挂载,这可能和目标有点儿差距。在一个真实场景里,用户可能会把这个锁定到一类 IP 地址,如192.168.1.0/24。不要将127.0.0.1替换为*从而对全世界开放,这是在玩儿火!

    为了安全起见,我们这里做了只读(ro)挂载,但是用户可以通过将ro替换成rw做可读写挂载。别忘了如果要这么做的话,需要在async标志后面加上一个no_root_squash标志,但是在实施之前请先考虑一下安全性。

    将NFS上共享的目录挂载到/mnt文件夹下,导出之前在/etc/exports里指定的文件系统,然后重启NFS服务以生效:

    1. # mount -t nfs 127.0.0.1:/export /mnt
    2. # exportfs -a
    3. # service nfs-kernel-server restart

    现在准备好运行基础设施容器:

    1. # docker run -ti —name nfs_client —privileged -v /mnt:/mnt
    2. busybox /bin/true

    而现在可以运行容器——不需要权限也无须拥有底层实现的知识就可以访问目录:

    1. # docker run -ti —volumes-from nfs_client debian /bin/bash
    2. root@079d70f79d84:/# ls /mnt
    3. myb
    4. root@079d70f79d84:/# cd /mnt
    5. root@079d70f79d84:/mnt# touch asd
    6. touch: cannot touch `asd’: Read-only file system

    采用一个命名规范以提高运维效率 如果需要管理大量的这种容器,可以通过设定一个命名规范,例如,针对一个暴露了/opt/database/livepath的容器起名为—name nfs_client_opt_database_live来简化管理。

    这种挂载一个中央授权访问的共享资源供其他人在多个容器中使用的模式真的很强大,它可以使开发工作流变得更加简单。

    安全性是无可替代的 请记住这一技巧只提供隐晦的(近乎没有)安全性保证。正如最后看到的那样,任何人只要能够运行Docker便拥有宿主机上的root权限。

    作为一名工程师,如果发现自己常常苦恼的问题是,在其他机器上没有自己雪花般美丽独特的个人开发环境里的程序或配置,那么这一技巧也许正好适用。类似地,如果想和其他人分享自己精心定制的开发环境,Docker可以让这件事情变得更简单。

    问题

    想在其他人的机器上访问自己定制的开发环境。

    解决方案

    用自己的配置创建一个容器,然后把它放到注册中心上。

    讨论

    作为演示,这里将使用一个我们的开发工具镜像。读者可以通过运行docker pull dockerinpractice/docker-dev-tools-image来下载它。如果想查看Dockerfile,仓库地址是https://github.com/docker-in-practice/docker-dev-tools-image。

    运行该容器非常简单——直接执行docker run -t -i dockerinpractice/docker-dev-tools-image就能给用户一个开发环境的shell。这里读者可以使用我们默认的配置,也可以就配置方面给我们一些建议。

    本技巧和其他技巧配合起来更能体现其威力。这里读者可以看到一个开发工具容器,用来在宿主机网络和IPC栈上展示一个用户图形界面(GUI),然后挂载宿主机上的代码:

    1. docker run -t -i \
    2. -v /var/run/docker.sock:/var/run/docker.sock \ 1
    3. -v /tmp/.X11-unix:/tmp/.X11-unix \ 2)       
    4. -e DISPLAY=$DISPLAY \ 3
    5. net=host ipc=host \ 4)  
    6. -v /opt/workspace:/home/dockerinpractice \ 5
    7. dockerinpractice/docker-dev-tools-image

    (1)挂载Docker套接字以访问宿主机上的Docker守护进程

    (2)挂载X服务器Unix域套接字可以启动一个基于用户图形界面的应用(见技巧26)

    (3)设置构建容器时的环境变量以使用宿主机的显示器

    (4)这些参数绕过容器的网桥并且通过这些参数可以访问宿主机上的进程间通信文件(见技巧97)

    (5)挂载工作区到该容器的home目录

    上述命令提供了一个环境,它能够访问宿主机的下列资源:

    • 网络;
    • Docker守护进程(运行普通Docker命令,就像在宿主机上一样);
    • 进程间通信(IPC)文件;
    • 如果需要的话,用X服务器启动基于用户图形界面的应用。

    宿主机安全性 挂载宿主机目录时,小心不要挂载任何重要的目录,因为可能会破坏文件。通常需要避免挂载宿主机上root用户的任何目录。

    虽然本书的大部分内容都是关于正在运行的容器的,但是也有一些与在宿主机上运行容器相关的实战技巧不容易被注意到。我们将看看如何让GUI应用程序工作,包括顺畅地退出一个启动的容器而不是杀掉它,检查容器的状态和源镜像,以及关闭这些容器。

    在技巧14中,我们已经看到一个在容器里由VNC服务器提供的GUI。这是一种在Docker容器中查看应用程序的方法,它是内置的,只需要一个VNC客户端便可以使用。

    幸好有更轻量、集成度更高的方法在桌面上运行GUI,不过这需要做更多的设置。它会在宿主机上挂载一个目录以管理和X服务器的通信,以便容器可以访问到它。

    问题

    想要在一个容器里运行GUI,就像是正常的桌面应用一样。

    解决方案

    使用自己的用户凭证和程序创建镜像,然后将用户的X服务器绑定挂载到它上面。

    讨论

    图4-6展示了最终设置的工作原理。

    容器会通过挂载宿主机的/tmp/.X11目录链接到宿主机,而这正是容器可以在宿主机的桌面上完成操作的原因所在。

    首先在一个觉得合适的地方创建一个新目录,然后通过id命令确定用户ID和组ID,如代码清单4-1所示。

    4

    图4-6 和宿主机的X服务器通信

    代码清单4-1 设置目录然后找出具体的用户信息

    1. $ mkdir dockergui
    2. $ cd dockergui
    3. $ id 1
    4. uid=1000(dockerinpractice) \ 2
    5. gid=1000(dockerinpractice) \ 3
    6. groups=1000(dockerinpractice),10(wheel),989(vboxusers),990(docker)

    (1)收集Dockerfile所需的用户信息

    (2)记住用户ID(uid)。在这个例子里,它是1000

    (3)记住组ID(gid)。在这个例子里,它是1000

    现在创建一个叫Dockerfile的文件,如下所示:

    1. FROM ubuntu:14.04
    2. RUN apt-get update
    3. RUN apt-get install -y firefox 1
    4. RUN groupadd -g GID USERNAME 2
    5. RUN useradd -d /home/USERNAME -s /bin/bash \
    6. -m USERNAME -u UID -g GID 3)        
    7. USER USERNAME 4
    8. ENV HOME /home/USERNAME 5
    9. CMD /usr/bin/firefox 6

    (1)安装Firefox作为GUI应用。用户可以把这个换成任何想要安装的应用

    (2)把宿主机上的组添加到镜像里,把GID替换为组ID,把USERNAME替换为用户名

    (3)把用户账号加到镜像里,把USERNAME替换为用户名,把UID替换为用户ID,把GID替换为组ID

    (4)镜像应当以刚创建的用户身份运行。把USERNAME替换为用户名

    (5)正确地设置HOME变量。把USERNAME替换为用户名

    (6)默认在启动时运行Firefox

    现在就可以基于该Dockerfile来构建镜像,然后把结果标记为“gui”了,如代码清单4-2所示。

    代码清单4-2 构建gui镜像

    1. $ docker build -t gui .

    以代码清单4-3所示的方式运行它。

    代码清单4-3 运行gui镜像

    1. docker run -v /tmp/.X11-unix:/tmp/.X11-unix \ 1
    2. -e DISPLAY=$DISPLAY gui 2
    3. -h $HOSTNAME -v $HOME/.Xauthority:/home/$USER/.Xauthority 3

    (1)绑定挂载X服务器目录到容器……

    (2)……在容器里将DISPLAY变量设置成宿主机上的相同值,这样程序便能知道与哪个X服务器通信……

    (3)……并为容器提供合适的授权凭证

    会看到弹出一个Firefox窗口!

    可以使用这一技巧来避免桌面工作与开发工作混在一起。以Firefox为例,出于测试目的,开发人员可能想要看到应用程序在没有Web缓存、书签或者搜索历史的情况下的行为,并且这种查看可以重复。如果在尝试启动镜像和运行Firefox的时候出现无法打开显示器这样的出错信息的话,不妨查看技巧58以获取关于使容器启动的图形应用在宿主机上得以展示的更多方法。

    虽然Docker命令提供了查看镜像和容器信息的能力,有时候用户也许也想了解这些Docker对象的内部元数据的更多信息。

    问题

    想要找出一个容器的IP地址。

    解决方案

    使用docker inspect查询和过滤容器的元数据。

    讨论

    docker inspect命令提供了一个以JSON格式查看Docker容器内部元数据的能力。该命令会产生大量的输出,因此在代码清单4-4中仅列出了一个镜像元数据的简明摘要。

    代码清单4-4 检查镜像的原始输出

    1. $ docker inspect ubuntu | head
    2. [{
    3.   Architecture”: amd64”,
    4.   Author”: “”,
    5.   Comment”: “”,
    6.   Config”: {
    7.     AttachStderr”: false,
    8.     AttachStdin”: false,
    9.     AttachStdout”: false,
    10.     Cmd”: [
    11.       “/bin/bash
    12. $

    用户可以通过名字或ID来查看镜像和容器,不过它们的元数据会不太一样。例如,一个容器会有像“State”之类的运行时字段,但镜像是没有的(镜像是没有状态的)。

    在这个例子里,用户想要在宿主机上找出一个容器的IP地址。为了做到这一点,可以使用docker inspect命令并附带format标志(如代码清单4-5所示)。

    代码清单4-5 确定一个容器的IP地址

    1. docker inspect \ 1
    2. format ‘{{.NetworkSettings.IPAddress}}’ \ 2
    3. 0808ef13d450 3

    (1)使用docker inspect命令

    (2)附加format标志。该标志会使用Go模板(不属于本书的内容范畴)来格式化输出。这里,会从inspect输出中的NetworkSettings字段里提取出IPAddress

    (3)想要查看的DockerID

    本技巧对自动化来说非常有用,因为这个接口比其他Docker命令可能会更稳定些。代码清单4-6给出的命令列出了所有正在运行的容器的IP地址,然后对它们执行ping命令。

    代码清单4-6 拿到正在运行中的容器的IP然后逐个ping

    1. $ docker ps -q | \ 1
    2. xargs docker inspect format=’{{.NetworkSettings.IPAddress}}’ | \ 2
    3. xargs -l1 ping -c1 3
    4. PING 172.17.0.5 (172.17.0.5) 56(84) bytes of data.
    5. 64 bytes from 172.17.0.5: icmp_seq=1 ttl=64 time=0.095 ms
    6. —- 172.17.0.5 ping statistics —-
    7. 1 packets transmitted, 1 received, 0% packet loss, time 0ms
    8. rtt min/avg/max/mdev = 0.095/0.095/0.095/0.000 ms

    (1)拿到所有正在运行中的容器的ID

    (2)针对所有容器ID执行inspect命令以获取它们的IP地址

    (3)逐个取出IP地址,然后依次运行ping

    注意,因为ping只接受单个IP地址,所以必须给xargs传入一个额外的参数,告诉它针对每个单独的行执行该命令。

    设置一个正在运行的容器来测试 如果没有正在运行的容器的话,运行下面这条命令来获取一个:docker run -d ubuntu sleep 1000。

    如果容器终止时的状态对用户而言很重要,用户可能会想要了解docker killdocker stop之间的区别。这一差异在需要应用程序正常关闭以保存数据时显得尤为重要。

    问题

    想要干净地终止一个容器。

    解决方案

    使用docker stop而不是docker kill

    讨论

    要理解的关键点在于docker kill的行为和标准的kill命令行程序并不相同。

    除非另有说明,kill程序的默认工作方式是向指定的进程发送TERM信号(即信号值为15)。这个信号表示程序应该终止,但是不要强迫程序终止。当这个信号被处理时,大多数程序将执行某种清理工作,但是该程序也可以执行其他操作,包括忽略该信号。

    相反,KILL信号(即信号值为9)会强迫指定的程序终止。

    令人困惑的是,docker kill对正在运行的进程使用的是KILL信号,这使得该进程没办法处理终止过程。这就意味着一些诸如包含运行进程ID之类的文件可能会残留在文件系统中。根据应用程序管理状态的能力,如果再次启动容器,这可能会也可能不会造成问题。

    更令人不解的是,docker stop命令则像kill命令那样工作,发送的是一个TERM信号(见表4-1)。

    表4-1 停止和杀死容器

    命  令

    默认信号量

    默认信号量的值

    kill

    TERM

    15

    docker kill

    KILL

    9

    docker stop

    TERM

    15

    总而言之,如果想使用kill就不要使用docker kill,而且最好养成使用docker stop的习惯。

    在本机上安装Docker可能不是一件太困难的事情——有一个脚本可以很方便地用来做这件事,或者也就是几条命令添加几个适当的源到包管理器的事情。但是,当试图在其他宿主机上管理Docker安装时会很乏味。

    问题

    想要在与自己机器独立的Docker宿主机上启动容器。

    解决方案

    使用Docker Machine。

    讨论

    如果需要在多个外部宿主机上运行Docker容器,这一技巧就有用武之地了。需要它可能有下面几个原因:

    • 通过在自己物理机里置备一台虚拟机来测试Docker容器之间的网络;
    • 借助VPS供应商到一台更强力的机器上置备容器;
    • 冒着损毁宿主机的风险去进行一些疯狂实验;
    • 保留运行在多个云厂商的选择权。

    无论什么原因,Docker Machine也许就是答案。它也是更复杂的编排工具(如Docker Swarm)的门户。

    1.Docker Machine是什么

    Docker Machine主要是一个便利程序。它将大量配置外部宿主机的繁琐的指令包装起来,变成一些易于上手的命令。如果对Vagrant很熟悉,这有着类似的体验:通过一个一致的接口使置备和管理其他机器环境变得更简单。如果把注意力放回到第2章里的架构概览图,查看该图的方式之一便是把它想象成一个客户端,它可以方便地管理不同的Docker守护进程(见图4-7)。

    图4-7里列出的Docker宿主机提供商并不全,而且这个清单可能还会不断增长。在编写本书的时候有下列驱动可用,它允许使用给定的宿主机供应商来置备:

    • Amazon Web Services(AWS);
    • Digital Ocean;
    • Google Compute Engine; 
    • IBM Softlayer;
    • Microsoft Azure; 
    • Microsoft Hyper-V;
    • OpenStack; 
    • Rackspace;
    • Oracle VirtualBox;
    • VMware Fusion;
    • VMware vCloud Air;
    • VMware vSphere。

    4

    图4-7 Docker Machine作为外部宿主机的一个客户端

    根据驱动提供的功能,置备机器时必须指定的参数也会有很大的不同。与OpenStack的17个参数相比,用户的机器置备一台Oracle Virtualbox虚拟机在创建时只有3个可用的参数。

    2.Docker Machine不是什么

    需要指出的是,Docker Machine 不是一种 Docker 的集群解决方案。其他工具(像Docker Swarm)填补了这块功能,我们将在后面探讨这些。

    3.安装

    安装程序是一个简单的二进制文件。针对不同架构的下载链接和安装指令可以在https://github.com/docker/machine/releases找到。

    迁移二进制文件? 用户可能想要把二进制文件放置到一个标准的位置,如/usr/bin,然后在继续之前先确保它被重命名或者符号链接到了docker-machine,因为下载的文件可能会有一个更长的带有运行架构的后缀名。

    4.使用Docker Machine

    为了演示Docker Machine的用法,可以从创建一个上面正常运行着Docker守护进程的虚拟机开始。

    假定选用 VirtualBox 虚拟机管理器 为了让它能够正常运行,用户需要安装Oracle公司的VirtualBox。在大多数包管理器里均可以找到它。

    1. $ docker-machine create driver virtualbox host1 1
    2. INFO[0000] Creating CA: /home/imiell/.docker/machine/certs/ca.pem
    3. INFO[0000] Creating client certificate: /home/imiell/.docker/machine/certs/
    4. cert.pem
    5. INFO[0002] Downloading boot2docker.iso to /home/imiell/.docker/machine/cache/ 
    6. boot2docker.iso
    7. INFO[0011] Creating VirtualBox VM
    8. INFO[0023] Starting VirtualBox VM
    9. INFO[0025] Waiting for VM to start
    10. INFO[0043] host1 has been created and is now the active machine.(2
    11. INFO[0043] To point your Docker client at it, run this in your shell:
    12. $(docker-machine env host1)  (3

    (1)使用docker-machine的create子命令来创建一个新的宿主机,然后通过—driver参数来指定它的类型。该宿主机已经被命名为host1

    (2)现在机器已经创建好了

    (3)运行这个命令来设置DOCKER_HOST环境变量,这将会设置Docker命令执行时所在的默认宿主机

    Vagrant 用户在这里应该会有种找到家的感觉。通过执行上述命令,现在已经创建了一台机器,并且可以在上面管理Docker。如果按照输出里给出的指令操作,可以直接使用SSH连接到新的虚拟机:

    1. $ eval $(docker-machine env host1) 1) 
    2. $ env | grep DOCKER 2)              
    3. DOCKER_HOST=tcp://192.168.99.101:2376 (3) 
    4. DOCKER_TLS_VERIFY=yes 4)     
    5. DOCKER_CERT_PATH=/home/imiell/.docker/machine/machines/host1 
    6. DOCKER_MACHINE_NAME=host1
    7. $ docker ps a 5)                             
    8. CONTAINERID  IMAGE  COMMAND  CREATED   STATUS  PORTS  NAMES
    9. $ docker-machine ssh host1 6
    10.             ##    .
    11.          ## ## ##    ==
    12.         ## ## ## ##    ===
    13.       /“”””””””””””””””\/ ===
    14.    ~ { ~~ ~ ~~ ~~ ~ / ===- ~
    15.       __ o     /
    16.        \  \    /
    17.        ____/
    18.            _         _
    19. | |    | ||__ \ | | _  | | __ __
    20. | \ / \ / | | ) / ` |/ \ / | |/ / _ \ ‘|
    21. | |) | () | () | | / / (| | () | (| <  / |
    22. |_./ _/ _/ _|___,|_/ _||\__||
    23. Boot2Docker version 1.5.0, build master : a66bce5 -
    24.    Tue Feb 10 23:31:27 UTC 2015
    25. Docker version 1.5.0, build a8a31ef
    26. docker@host1:~$

    (1)$()可以拿到docker-machine env命令的输出,然后将它应用到用户的环境。docker-machine env会输出一组命令,用户可以用它来设置默认宿主机上的Docker命令

    (2)环境变量名均以DOCKER_开头

    (3)DOCKER_HOST变量即虚拟机上Docker守护进程的端口

    (4)这些变量是用来处理客户端和新宿主机之间连接的安全性

    (5)docker命令现在被指向了创建好的虚拟机上,而不再是之前所使用的宿主机。在新的虚拟机上并没有创建任何的容器,所以输出结果是空

    (6)ssh子命令将会直接连接到新的虚拟机上

    5.管理宿主机

    通过一个客户端机器管理多台Docker宿主机可能使追踪背后的运作很困难。为了简化这一点,Docker Machine提供了众多的管理命令,如表4-2所示。

    表4-2 一系列docker machine命令

    子 命 令

    行  为

    c reate

    创建一台新的机器

    ls

    列出Docker宿主机

    stop

    停止机器

    start

    启动机器

    restart

    停止并随后启动机器

    rm

    销毁一台机器

    kill

    杀掉一台机器

    inspect

    以JSON的格式返回机器的元数据

    config

    返回连接机器所需的配置信息

    ip

    返回一台机器的IP地址

    url

    返回一个机器上Docker守护进程的URL

    upgrade

    将宿主机上的Docker升级到最新版本

    下面这个例子列出了两台机器。当前活跃的机器旁边会标有一个星号,并且拥有一个相关的状态,类似于容器或进程的状态标识:

    1. $ docker-machine ls
    2. NAME  ACTIVE DRIVER   STATE  URL            SWARM
    3. host1     virtualbox Running tcp://192.168.99.102:2376
    4. host2     virtualbox Running tcp://192.168.99.103:2376

    事实上,这里可以看作是将机器转变为进程,就像Docker本身也可以看作是将环境转变为进程。

    切换回来 读者可能会疑惑该怎么切回到最开始宿主机上的那个Docker实例。在编写本书时,我们仍然没有找到一种简单的办法来做到这一点。用户可以通过docker-machine rm删除所有机器,如果实在没有办法,也可以通过unset DOCKER_HOST DOCKER_TLS_VERIFY DOCKER_CERT_PATH手动重置之前设置的环境变量来实现这一点。

    尽管Dockerfile的简单性使其成为一款强大的省时工具,但是这里仍然有一些细节之处可能会让人感到困惑。我们将学习一些节省时间的功能及其具体细节,从ADD指令开始,然后会覆盖到Docker构建的缓存,如何让它失效,以及怎么操作它来获得收益。

    要了解Dockerfile指令相关的完整内容,请参考Docker的官方文档。

    尽管可以在Dockerfile里使用RUN命令和基本的shell来添加文件,但是这很快就会变得无法维护。ADD命令被加入Dockerfile命令清单里正是为了满足优雅地将大量文件放入镜像的需求。

    问题

    想要以一种简便的方式下载和解压一个压缩包到镜像里。

    解决方案

    打包并压缩文件,随后在Dockerfile里使用ADD指令。

    讨论

    通过mkdir add_example && cd add_example为这次的Docker构建创造一个全新的环境,然后获取一个压缩包并给它赋予一个名称,以便稍后引用:

    1. $ curl \
    2. https://www.flamingspork.com/projects/libeatmydata/libeatmydata-105.tar.gz > \
    3. my.tar.gz

    在这个案例里,我们使用的是其他技术创建的tar文件,但是它也可以是任意你喜欢的压缩包:

    1. FROM debian
    2. RUN mkdir -p /opt/libeatmydata
    3. ADD my.tar.gz /opt/libeatmydata/
    4. RUN ls -lRt /opt/libeatmydata

    通过docker build —no-cache .构建这个Dockerfile,并且输出应该看似是下面这样:

    1. $ docker build no-cache .
    2. Sending build context to Docker daemon 422.9 kB
    3. Sending build context to Docker daemon
    4. Step 0 : FROM debian
    5. —-> c90d655b99b2
    6. Step 1 : RUN mkdir -p /opt/libeatmydata
    7. —-> Running in fe04bac7df74
    8. —-> c0ab8c88bb46
    9. Removing intermediate container fe04bac7df74
    10. Step 2 : ADD my.tar.gz /opt/libeatmydata/
    11. —-> 06dcd7a88eb7
    12. Removing intermediate container 3f093a1f9e33
    13. Step 3 : RUN ls -lRt /opt/libeatmydata
    14. —-> Running in e3283848ad65
    15. /opt/libeatmydata:
    16. total 4
    17. drwxr-xr-x 7 1000 1000 4096 Oct 29 23:02 libeatmydata-105
    18. /opt/libeatmydata/libeatmydata-105:
    19. total 880
    20. drwxr-xr-x 2 1000 1000  4096 Oct 29 23:02 config
    21. drwxr-xr-x 3 1000 1000  4096 Oct 29 23:02 debian
    22. drwxr-xr-x 2 1000 1000  4096 Oct 29 23:02 docs
    23. drwxr-xr-x 3 1000 1000  4096 Oct 29 23:02 libeatmydata
    24. drwxr-xr-x 2 1000 1000  4096 Oct 29 23:02 m4
    25. -rw-rr 1 1000 1000  4096 Oct 29 23:02 config.h.in
    26. […edited…]
    27. -rw-rr 1 1000 1000  1824 Jun 18 2012 pandora_have_better_malloc.m4
    28. -rw-rr 1 1000 1000  742 Jun 18 2012 pandora_header_assert.m4
    29. -rw-rr 1 1000 1000  431 Jun 18 2012 pandora_version.m4
    30. —-> 2ee9b4c8059f
    31. Removing intermediate container e3283848ad65
    32. Successfully built 2ee9b4c8059f

    可以看到,Docker守护进程在这里输出的内容(所有文件的扩展输出已被修改),tar包被解压到了目标目录。Docker能够解压绝大多数标准类型(.gz、.bz2、.xz、.tar)的压缩文件。

    值得一提的是,尽管可以指定一个URL来下载压缩包,但是只有存储在本地文件系统中的包才会被自动解压。这可能容易导致混淆。

    如果使用下面的Dockerfile重复前面的过程,我们会发现文件被下载下来,但是并没有被解压:

    1. FROM debian 1
    2. RUN mkdir -p /opt/libeatmydata
    3. ADD \
    4. https://www.flamingspork.com/projects/libeatmydata/libeatmydata-105.tar.gz \
    5. /opt/libeatmydata/ 2
    6. RUN ls -lRt /opt/libeatmydata

    (1)通过一个URL从互联网上获取文件

    (2)通过目录名和末尾的斜杠指明目标目录。没有末尾的斜杠的话,参数将被视作是下载好的文件的文件名

    下面是最终的构建输出:

    1. Sending build context to Docker daemon 422.9 kB
    2. Sending build context to Docker daemon
    3. Step 0 : FROM debian
    4. —-> c90d655b99b2
    5. Step 1 : RUN mkdir -p /opt/libeatmydata
    6. —-> Running in 6ac454c52962
    7. —-> bdd948e413c1
    8. Removing intermediate container 6ac454c52962
    9. Step 2 : ADD \
    10. https://www.flamingspork.com/projects/libeatmydata/libeatmydata-105.tar.gz \
    11. /opt/libeatmydata/
    12. Downloading [==================================================>] \
    13. 419.4 kB/419.4 kB
    14. —-> 9d8758e90b64
    15. Removing intermediate container 02545663f13f
    16. Step 3 : RUN ls -lRt /opt/libeatmydata
    17. —-> Running in a947eaa04b8e
    18. /opt/libeatmydata:
    19. total 412
    20. -rw———- 1 root root 419427 Jan 1 1970 \
    21. libeatmydata-105.tar.gz 1)            
    22. —-> f18886c2418a
    23. Removing intermediate container a947eaa04b8e
    24. Successfully built f18886c2418a

    (1)libeatmydata-105.tar.gz文件被下载下来并放到了/opt/libeatmydata目录下,但是没有自动解压

    注意,如果在之前的Dockerfile中的ADD那行没有带末尾的斜杠符的话,文件会被下载下来然后以该文件名保存。末尾的斜杠符指明该文件应该被下载下来,并放置到指定的目录里。

    所有的新文件和新目录的所有者为root(或是那个在容器里组或者用户ID为0的用户)。

    不想解压? 如果想要从本地文件系统添加一个压缩文件但不想解压它,可以使用COPY命令,它和ADD命令看上去的确很像,但它不会自动解压任何文件。

    文件名里有空格?

    如果文件名里带有空格,需要在ADD(或COPY)指令里用双引号的形式标明:

      ADD “space file.txt” “/tmp/space file.txt”

    借助Dockerfile进行构建时可以利用一个很有用的缓存功能:已经运行过的构建步骤只有在命令内容发生变化时才会被重新执行。代码清单4-7展示了第1章中的to-do应用程序重新构建的输出。

    代码清单4-7 重新构建时使用缓存

    $ docker build .
    Sending build context to Docker daemon 2.56 kB
    Sending build context to Docker daemon
    Step 0 : FROM node
     —-> 91cbcf796c2c
    Step 1 : MAINTAINER ian.miell@gmail.com
    —-> Using cache (1)             
    —-> 8f5a8a3d9240 (2)               
    Step 2 : RUN git clone -q https://github.com/docker-in-practice/todo.git
     —-> Using cache
     —-> 48db97331aa2
    Step 3 : WORKDIR todo
     —-> Using cache
     —-> c5c85db751d6
    Step 4 : RUN npm install > /dev/null
     —-> Using cache
     —-> be943c45c55b
    Step 5 : EXPOSE 8000
     —-> Using cache
     —-> 805b18d28a65
    Step 6 : CMD npm start
     —-> Using cache
     —-> 19525d4ec794
    Successfully built 19525d4ec794 (3)

    (1)表明用户在构建时使用了缓存

    (2)指定已缓存的镜像/分层ID

    (3)最后镜像被“重新构建”,但是实际上并没有什么改动

    这样做的确很有用而且省时,但是它往往并不是用户期望的行为。

    以前面的Dockerfile为例,假设改动了源代码并推送到了Git仓库。新代码将不会被检出,因为git clone命令本身并没有发生变化。就Docker构建而言,它是相同的,因此缓存的镜像会在这次构建过程中被复用。

    在这类情况下,用户可能想要的是不使用缓存进行重新构建。

    问题

    想要不使用缓存重新构建Dockerfile。

    解决方案

    构建镜像时加上—no-cache标志。

    讨论

    为了强制重新构建时不使用镜像缓存,需要在运行docker build时加上—no-cache标志。代码清单4-8中加上—no-cache运行之前的构建。

    代码清单4-8 强制重新构建时不使用缓存

    $ docker build —no-cache . (1)         
    Sending build context to Docker daemon 2.56 kB
    Sending build context to Docker daemon
    Step 0 : FROM node
     —-> 91cbcf796c2c
    Step 1 : MAINTAINER ian.miell@gmail.com
     —-> Running in ca243b77f6a1 (2)         
     —-> 602f1294d7f1 (3)              
    Removing intermediate container ca243b77f6a1
    Step 2 : RUN git clone -q https://github.com/docker-in-practice/todo.git
     —-> Running in f2c0ac021247
     —-> 04ee24faaf18
    Removing intermediate container f2c0ac021247
    Step 3 : WORKDIR todo
     —-> Running in c2d9cd32c182
     —-> 4e0029de9074
    Removing intermediate container c2d9cd32c182
    Step 4 : RUN npm install > /dev/null
     —-> Running in 79122dbf9e52
    npm WARN package.json todomvc-swarm@0.0.1 No repository field.
     —-> 9b6531f2036a
    Removing intermediate container 79122dbf9e52
    Step 5 : EXPOSE 8000
     —-> Running in d1d58e1c4b15
     —-> f7c1b9151108
    Removing intermediate container d1d58e1c4b15
    Step 6 : CMD npm start
     —-> Running in 697713ebb185
     —-> 74f9ad384859
    Removing intermediate container 697713ebb185
    Successfully built 74f9ad384859 (4)

    (1)通过—no-cache标志在重新构建Docker镜像时无视缓存的分层

    (2)这次没有提到在使用缓存

    (3)中间镜像的ID与代码清单4-7中列出的不同

    (4)一个新的镜像构建完成

    展示的输出没有提到缓存,而且每一个中间层的ID都和代码清单4-7中的输出不同。

    相同的问题同样可能发生在其他场景中。我们早期在使用Dockerfile时曾经诧异于一个网络波动导致一条命令无法正确地从网络获取到某个东西,但是该命令却没有报错。我们继续调用docker build,但是结果里的bug始终存在!这是因为一个“错误”的镜像已经被加入缓存中,而我们之前并没有理解Docker构建缓存的工作机制。最终,我们解决了这个问题。

    使用—no-cache标志通常足以解决在缓存方面遇到的任何问题。但是有时候往往需要的是一个更细粒度的解决方案。如果有一个构建需要花费很长时间,例如,想使用缓存到一个指定层,然后重新运行一条命令使其上的缓存失效并创建一个新镜像。

    问题

    想要在Dockerfile构建中从一个指定的点开始失效Docker构建缓存。

    解决方案

    在命令后面增加一条无害的注释,从而让缓存失效。

    讨论

    不妨从https://github.com/docker-in-practice/todo的Dockerfile开始上手,我们已经完成了一次构建,并随后在CMD这一行上添加了一条注释。我们可以在这里看到再次执行docker build的输出:

    $ docker build . (1)                  
    Sending build context to Docker daemon 2.56 kB
    Sending build context to Docker daemon
    Step 0 : FROM node
     —-> 91cbcf796c2c
    Step 1 : MAINTAINER ian.miell@gmail.com
     —-> Using cache
     —-> 8f5a8a3d9240
    Step 2 : RUN git clone -q https://github.com/docker-in-practice/todo.git
     —-> Using cache
     —-> 48db97331aa2
    Step 3 : WORKDIR todo
     —-> Using cache
     —-> c5c85db751d6
    Step 4 : RUN npm install
     —-> Using cache
     —-> be943c45c55b
    Step 5 : EXPOSE 8000
     —-> Using cache (2)    
     —-> 805b18d28a65
    Step 6 : CMD [“npm”,”start”] #bust the cache (3)
     —-> Running in fc6c4cd487ce
     —-> d66d9572115e (4)                 
    Removing intermediate container fc6c4cd487ce
    Successfully built d66d9572115e

    (1)一次“正常” docker build

    (2)缓存用到这里结束

    (3)缓存已经失效但是命令仍然有效地维持不变

    (4)一个新的镜像被创建出来

    这一技巧的原理在于Docker将非空的更改均当作一行新的命令来对待,因此缓存的镜像层便不会再被复用。

    读者可能会感到疑惑(我们第一次看到Docker时也有同感),Docker的分层是否可以在镜像之间迁移并且合并它们,就像它们是Git中的更改集一样。在Docker里,至少当前是不可能实现的。一个分层被定义成仅仅是一个指定镜像的更改集。因此,一旦缓存被破坏了,构建里的命令就不能再复用它。

    正因为如此,如果可以的话建议最好将不太可能变动的命令放到更靠近Dockefile开头的位置。

    如果你跟我们一样(并且仔细阅读了本书),Docker知识日益增长,使用Docker成瘾,从而启动更多数量的容器,而且会在宿主机上下载更多的镜像。

    随着时间的流逝,Docker将会消耗越来越多的资源,而一些容器和卷的清理将变得很有必要——我们将展示如何做以及这样做的原因。我们也会介绍一些用来保持Docker环境干净整洁的可视化工具,以便不喜欢命令行的用户可以从中解脱出来。

    Docker守护进程在机器上以root用户运行在后台,在暴露给用户的同时,也给了它很大的权利。需要使用sudo只是一个结果,但是这样做会变得不太方便,而且也会造成一些第三方Docker工具无法使用。

    问题

    想要无须sudo便可以运行docker命令。

    解决方案

    将自身加到docker组。

    讨论

    Docker使用一个用户组来围绕着Docker Unix域套接字来管理权限。出于安全的原因,发行版默认不会将用户加到该用户组中。

    通过将用户加到该组,用户便能以自己的身份使用docker命令:

    $ sudo addgroup -a username docker

    重新启动Docker然后完全注销并再次登录,或者如果更简单一些的话,直接重启机器。现在以自己的用户身份运行Docker时无须再记住敲sudo或者设置一个别名了。

    Docker新用户经常抱怨的是,在短时间内,用户可能因为各种情况在系统上残留许多容器,而命令行没有用于管理这些的标准工具[1]

    问题

    想要整理系统上的容器。

    解决方案

    设置一个别名来运行清理旧容器的命令。

    讨论

    这里最简单的方式自然是删除所有容器。显然,这是一个有风险的方案,只应该在确定这就是预期的行为时使用。下列命令将会删除在宿主机上的所有容器:

    $ docker ps -a -q | (1)
    xargs —no-run-if-empty docker rm –f (2)

    (1)获取所有容器ID的列表,包括正在运行的以及已停止的,然后将它们传给……

    (2)……docker rm -f命令,被传入的任意容器将会被删除,即使它们还处于运行状态

    简要解释一下xargs命令,它会获取输入的每一行内容,并将它们全部作为参数传递给后续命令。为了防止报错,我们这里传入了一个额外的参数—no-run-if-empty,这可以避免在前面的命令完全没有输出的情况下执行该命令。

    如果有正在运行的容器想要保留,却又想删除所有已经退出的容器的话,不妨过滤一下docker ps命令的返回的条目:

    docker ps -a -q —filter status=exited | \ (1)
    xargs —no-run-if-empty docker rm (2)

    (1)—filter标志会告知docker ps命令想要返回的容器。在这种情况下限制成状态为已经退出的那些容器。也可以选择处于正在运行中或者正在重启状态的容器

    (2)这次不用再强行删除容器,因为根据给定的过滤参数,它们本身就不应该处于运行状态

    作为更高级用例的示范,下列命令将列出所有返回非零错误码的容器。如果系统上有许多容器而用户想要自动检查和删除那些异常退出的容器,就可能需要这样做:

    comm -3 \ (1)             
    <(docker ps -a -q —filter=status=exited | sort) \ (2)    
    <(docker ps -a -q —filter=exited=0 | sort) | \ (3)
    xargs —no-run-if-empty docker inspect > error_containers (4)

    (1)运行comm命令来比较两个文件内容的差异。加上-3 参数将不会显示同时出现在两个文件里的行内容(这些容器的退出码都是0),然后输出其他不同的部分

    (2)找出退出的容器 ID,给它们排序,然后以文件形式传给comm

    (3)找出退出码为0的容器,给它们排序,然后以文件形式传给comm

    (4)对非0退出码(comm命令管道的输出)的容器执行docker inspect,并将输出结果保存到error_containers文件中

    bash中的进程替换 你也许还没看到过这种用法,bash里的“<(命令)”语法被称为进程替换。它允许把一个命令的输出结果作为一个文件,传给其他命令,这在无法使用管道输出的时候非常有用。

    上述示例相对比较复杂,但是它展示了将不同的工具命令组合在一起的威力。它会输出所有已停止的容器的ID,然后挑出那些非0退出码的容器(即那些异常方式退出的容器)。如果读者还在努力理解这个用法的话,不妨先单独运行每条命令,然后理解它们的含义,这样有助于更快地了解整个过程。

    像这样的命令可以用来在生产环境里采集容器信息。可能需要调整一下它,改为运行一个从容作业来清除正常退出的容器。

    将单行代码设置为命令

    可以给命令设置别名,以便在登录到宿主机上后更容易执行。为了达成这一点,需要在~/.bashrc文件里加上如下代码行:

    alias dockernuke=’docker ps -a -q | \ 
    xargs —no-run-if-empty docker rm -f’

    然后,在下一次登录时,从命令行运行dockernuke,将删除在系统上找到的任何Docker容器。

    我们发现这样做节省的时间是相当可观的。但是要小心!这种方式同样也非常容易误删生产环境的容器,我们可以证明。即使足够小心,不去删除正在运行的容器,仍然可能会误删那些没有运行但却仍然有用的纯数据容器。

    尽管卷是Docker的一个强大的功能,伴随而来的也有一些显著的运维缺陷。

    因为卷可以在不同的容器之间共享,所以在挂载它们的容器被删除时无法清空掉这些卷。试想一下图4-8中描述的场景。

    4

    图4-8 当容器被删除时/var/db下会发生什么

    “简单!”你可能会这样想,“在最后一个引用的容器被删除时把卷删掉不就行了!”事实上,Docker可以采取这种手段,这也是垃圾回收式编程语言从内存中删除对象时所采用的方法:当没有其他对象引用它时,它便可以被删除。

    但是Docker认为这可能会让人们不小心丢失重要的数据,并且最好把是否在删除容器的时候删除卷的决定权交给用户。这样做带来的一个不幸的副作用便是,默认情况下,卷会一直保留在Docker守护进程所在的宿主机磁盘上,直到它们被手动删除。

    如果这些卷填满了数据的话,磁盘可能就会没有存储空间,因此最好关注一下管理这些孤立卷的方法。

    问题

    孤立的Docker卷挂载到本地用掉了大量的磁盘空间。

    解决方案

    在调用docker rm命令时加上-v标志,或者如果忘记的话用一个脚本来销毁它们。

    讨论

    在图4-8所描述的场景中,如果在调用docker rm时总是加上-v标志的话便可以确保/var/db最后被删除掉。-v标志会将那些没有被其他容器挂载的关联卷一一删除。幸好,Docker很聪明,它知道是否有其他容器挂载该卷,因此不会出现什么意外尴尬的情形。

    最简单的方式莫过于养成在删除容器时加上-v标志这样的好习惯。这样的话可以保留对容器是否删除卷的控制权。

    而这种方式的问题在于用户可能会不想每次都删除卷。如果用户正在写大量数据到这些卷的话,极有可能不希望丢失这些数据。此外,如果养成了这样的习惯,很有可能就会变成自动的了,而用户将会在删除某些重要东西之后才反应过来,但已经为时已晚。

    在这类情况下,用户将需要使用一个脚本来做这件事情,而为了方便,它也是放到容器里的——Docker化。注意需要用root权限来运行:

    $ docker run \
     -v /var/run/docker.sock:/var/run/docker.sock \ (1)
     -v /var/lib/docker:/var/lib/docker \ (2)
     —privileged dockerinpractice/docker-cleanup-volumes (3)

    (1)挂载Docker服务器的套接字,这样就可以在容器里调用Docker

    (2)挂载Docker目录这样便可以删除孤立的卷

    (3)升级权限,这样才能有权限执行对孤立卷的删除操作

    上述命令将删除任何现有容器不再访问的卷。输出内容看上去会是下面这个样子:

    $ docker run -v /var/run/docker.sock:/var/run/docker.sock \
     -v /var/lib/docker:/var/lib/docker —privileged 951acdb777bf
    
    Delete unused volume directories from /var/lib/docker/volumes
    Deleting 659cfdc5d394ec7ad5942862ba5feb1d24c9f67ca314462207835ef5bf657131 
    In use 6ae01c5524267c8f01f1d1e83933b494fdb5c709d9468122b470bfcdd5a5b03d 
    Deleting 73260d192a0a4d0ebc3606d9daf7137ab220e41cbbfe919ef1dded01a2f37b29
    
    Delete unused volume directories from /var/lib/docker/vfs/dir
    Deleting 659cfdc5d394ec7ad5942862ba5feb1d24c9f67ca314462207835ef5bf657131
    In use 6ae01c5524267c8f01f1d1e83933b494fdb5c709d9468122b470bfcdd5a5b03d
    Deleting 73260d192a0a4d0ebc3606d9daf7137ab220e41cbbfe919ef1dded01a2f37b29

    如果怕执行这条命令可能删掉不想删除的东西,可以在执行命令的末尾加上—dry-run以阻止它实际删除任何东西。

    恢复数据 如果想要从一个不再被任何容器引用的未删除卷中恢复数据,需要以root用户身份查看/var/lib/docker/volumes文件夹里的内容。

    在使用Docker时,常常会发现打开的是一个交互式shell,然后因为它是容器的主进程,所以一旦退出会话容器便会被终止。幸运的是,有办法可以做到从一个容器解绑(而且,如果愿意的话,可以用docker attach命令再连到容器)。

    问题

    想要解绑一个容器的交互会话,同时不停掉它。

    解决方案

    按下Ctrl+P然后再按Ctrl+Q来解绑。

    讨论

    Docker很有建设性地实现了一个不太可能被其他应用使用也不太可能被意外按到的键序列。

    假设通过执行docker run -t -i -p 9005:80 ubuntu /bin/bash启动了一个容器,然后用apt-get安装了一个nginx Web服务器。用户想在宿主机上使用一个快捷的curl命令来测试该Web服务器能否被访问到localhost:9005。

    先按Ctrl+P然后再按Ctrl+Q。注意,不是所有3个键一起按!

    运行的容器具有-rm标志 如果运行时加上—rm标志,需要按Ctrl+C,一旦按下键序列就返回到终端了。容器会持续在后台运行。

    在演示Docker时,可能很难展示容器与镜像之间的不同——终端上显示的行并非是可视化的。此外,Docker的命令行工具对于想要杀掉和删除许多特定容器的需求可能会不太友好。通过创建一个即点即用的在宿主机上管理镜像和容器的工具可以解决这个问题。

    问题

    想要在宿主机上不通过命令行形式管理容器和镜像。

    解决方案

    使用DockerUI。

    讨论

    DockerUI是一个由Docker的核心开发人员创建的工具,可以在https://github.com/ crosbymichael/ dockerui找到和阅读相关的源代码。因为使用它无须任何前置要求,可以跳过这一步,直接去运行它:

    $ docker run -d -p 9000:9000 —privileged \
    -v /var/run/docker.sock:/var/run/docker.sock dockerui/dockerui

    它会在后台启动一个dockerui 容器。如果现在访问http://localhost:9000,将可以看到一个控制面板,里面展示了电脑上Docker的概览信息。

    容器管理功能可能是这里面最有用的一个功能版块了——访问容器页面将会列出正在运行的容器(包括dockerui容器本身),并且它还提供了一个展示全部容器的选项。在这里,用户可以对容器完成一些批量的操作(如杀掉它们)或者点击单个容器名跳转到容器具体的页面,然后完成对该容器的一些单独操作。举个例子,用户可以看到删除一个正在运行的容器的选项。

    镜像页面看起来和容器页面差不多,并且它也允许用户选中多个镜像然后完成一些批量操作。点击镜像ID将会出现一些有意思的选项,如基于该镜像创建一个容器和给镜像打个标签。

    记住DockerUI可能会落后于Docker官方所提供的功能——如果想要体验到最新和最好的功能,那可能还得被迫用命令行。

    Docker的文件分层系统是一个非常强大的理念,它可以节省空间,而且可以让软件的构建变得更快。但是一旦启用了大量的镜像,便很难搞清楚镜像之间是如何关联的。docker images -a命令会返回系统上所有镜像层的列表,但是对理解它们的关联关系而言这并不是一种对用户友好的方式——使用Graphviz,可以很方便地通过创建一个镜像树并做成镜像的形式来可视化镜像之间的关系。

    这也展示了Docker在把复杂的任务变得简单方面的强大实力。在宿主机上安装所有的组件来生产镜像时,老的方式可能会包含一长串容易出错的步骤,但是对Docker而言,这就变成了一个不太可能失败的可移植命令。

    问题

    想要以树的形式将存放在宿主机上的镜像可视化。

    解决方案

    使用一个我们之前为此任务创建的镜像作为一条单行命令来输出一个PNG或者获取一个Web视图。

    讨论

    依赖图的生成涉及使用一个我们之前提供的镜像,它里面包含了调用Graphviz来生成PNG图片文件的脚本。运行的命令里需要做的只是挂载Docker服务器套接字然后一切便准备就绪,如代码清单4-9所示。

    代码清单4-9 生成一个镜像的分层树

    $ docker run —rm \ (1)
    -v /var/run/docker.sock:/var/run/docker.sock \ (2)
    dockerinpractice/docker-image-graph > docker_images.png (3)

    (1)在生成镜像之后删除容器

    (2)挂载 Docker 服务器的Unix 域套接字,以便可以在容器里访问Docker服务器。如果已经改了Docker守护进程的默认配置,这将不会奏效

    (3)指定一个镜像然后生成一个PNG作为产品

    图4-9以PNG形式展示了一台机器的镜像树。从这张图中可以看出,nodegolang:1.3镜像拥有一个共同的根节点,然后golang:runtime只和golang:1.3共享全局的根节点。类似地,mesosphere镜像和ubuntu-upstart镜像也是基于同一个根节点构建的。

    读者可能会好奇这棵树上的全局根节点是什么。它是一个最小镜像(scratch image),实际上大小为0字节。

    4

    图4-9 一棵镜像树

    在Docker早期,许多用户在他们的镜像里添加SSH服务,这样一来便可以从外部通过一个shell来访问它们。Docker不主张这样做,它认为这相当于把容器当成是一台虚拟机(我们知道,容器不是虚拟机),并且为一个本不需要它的系统增加了额外的进程开销。很多人对此持反对意见的原因在于,一旦容器启动了,就没有一个简便的办法进到容器里面。结果,Docker引入了exec命令,它是一个更好地解决干涉和查看启动后容器内部问题的方案。这也是我们这里即将讨论的命令。

    问题

    想要在一个正在运行的容器里执行一些命令。

    解决方案

    使用docker exec命令。

    讨论

    在代码清单4-10中,我们将在后台(附加-d标志)启动一个容器然后告诉它一直休眠(不做任何事情)。我们把这条命令命名为sleeper

    代码清单4-10 运行一个容器,然后在上面执行docker exec命令

    docker run -d —name sleeper debian sleep infinity

    现在已经启动了一个容器,可以用Docker的exec命令对它做一些操作。该命令可以看成是有3种基本模式,如表4-3所示。

    表4-3 Docker exec模式

    模  式

    描  述

    基本

    在命令行上对容器同步地执行命令

    守护进程

    在容器的后台运行命令

    交互

    运行命令并允许用户与其交互

    我们先介绍基本模式。代码清单4-11给出的命令会在sleeper容器内部运行一个echo命令。

    代码清单4-11 从容器里运行echo命令

    $ docker exec sleeper echo “hello host from container”
    hello host from container

    注意,该命令的结构和docker run命令非常相似,但是代替镜像ID的是我们给定的一个正在运行的容器的ID。echo命令指代的是容器里面的echo可执行文件,而非容器外部的。

    守护进程模式会在后台运行该命令,用户将无法在终端看到输出结果。这可能适用于一些常规的清理任务,那些运行后就结束的任务,如代码清单4-12中列出的清理日志文件的任务。

    代码清单4-12 在一个容器里删除一周前的日志文件

    $ docker exec -d sleeper \(1)
    find / -ctime 7 -name ‘log’ -exec rm {} \; (2)
    $ (3)

    (1)运行命令时加上-d标志即可在后台以守护进程的形式运行

    (2)删除所有在最近7天没有做过更改并且以log结尾的文件

    (3)无论需要多长时间完成这一操作,该命令都会立即返回

    最后,我们来试试交互模式。这种模式允许用户在容器里运行任何喜欢的命令。要启用这一功能,通常需要指定用来在运行时交互的shell,在下面的代码里便是bash:

    $ docker exec -i -t sleeper /bin/bash
    root@d46dc042480f:/#

    -i-t参数和所熟悉的docker run做着相同的事情——它们会让命令成为可交互的,然后设置一个TTY设备,以便shell可以正常工作。在运行这一命令后,用户便拿到了一个在容器里面运行的命令提示符。

    在本章中,我们从理论讲到实践。Docker能为日常工作流带来的诸多可能性已经初见端倪。现在,读者不仅了解了Docker的架构,也涉及了日常使用遇到的一些问题及其解决方法。这是坚实的一小步,由此我们打下了从这里到更高阶Docker应用的基础。

    在本章中,读者已经学到了以下几项内容:

    • 如果需要从容器中获取外部数据,则应该访问卷;
    • SSHFS是一种无须额外配置即可访问其他计算机上数据的简单方法;
    • 在Docker里运行GUI应用只需要镜像做少量准备即可;
    • 构建过程中的缓存是一把双刃剑;
    • 可以使用数据容器来抽象数据的存放位置;
    • docker exec命令才是进入正在运行的容器内部的正确方式——抵制安装SSH。

    现在,我们将从每天都可能做的随意的实验性工作转到一个严谨的话题,即Docker的配置管理。


    [1]  Docker 1.13中已经引了入system子命令来完成清理残留的容器、镜像等工作。——译者注