第3章 将Docker用作轻量级虚拟机

    本章主要内容

    • 将虚拟机转换成Docker镜像
    • 管理容器的服务启动
    • 在工作的时候随时保存成果
    • 在机器上管理Docker镜像
    • 在Docker Hub上分享镜像
    • 即玩——即用——2048 Docker版

    自本世纪以来,虚拟机(virtual machine,VM)已经在软件开发和部署等领域广泛普及。机器到软件的抽象使互联网时代下的软件和服务的更替及管控变得更加轻松和廉价。

    虚拟机 一台虚拟机即是一个模拟真实计算机的应用程序,它通常会运行一个操作系统和一些应用。它可以被放到任何(兼容的)可用的物理资源上。最终用户对该软件的体验是,尽管它好像是在物理机上,但那些管理硬件的人员可以专注于大规模的资源分配。

    Docker并不是一项虚拟机技术。它不会模拟一台机器的硬件,也不会包括一个操作系统。一个Docker容器在默认情况下没有被约束于指定的硬件限制。如果说Docker抽象了什么的话,那便是它将服务运行的环境给虚拟化了,而不是机器本身。此外,Docker很难运行Windows软件(甚至为其他Unix衍生的操作系统编写的软件)。

    虽说从某些方面来看,可以把Docker当成是一台虚拟机使用,但事实上,对互联网时代的开发人员和测试人员而言,有没有初始化进程或者是否直接和硬件交互并没有什么重大意义。而Docker和虚拟机有一些显著的共性,如它对周围硬件具有较好的隔离性以及顺应更加细粒度的软件交付方式。

    本章将会带读者领略一些之前使用虚拟机但如今可以用Docker实现的场景。相比于虚拟机,使用Docker不会给用户带来任何明显的功能优势,但是Docker在更替和环境跟踪方面带来的速度及便利性也许可以改写开发流水线的游戏规则。

    在理想的情况下,从虚拟机迁移到容器也许就是在一个与虚拟机类似的发行版的Docker镜像上运行配置管理脚本这么简单。本节会为读者展示针对非理想情况的场景,该如何从虚拟机转换到容器。

    Docker Hub上并不是拥有所有的基础镜像,因此,针对一些小众的Linux发行版和用例,人们可能需要创建自己的镜像。同样的原则也适用于现有一台虚拟机想放到Docker里从而在其上迭代或者利用Docker生态系统优势的情况。

    在理想情况下,用户会想使用标准的Docker技术,如一些结合了标准配置管理工具(见第5章)的Dockerfile,从头开始构建一个等同于虚拟机的容器。然而,现实情况是,很多虚拟机都没有被仔细地做过配置管理。这一点的确可能发生,因为一台虚拟机从人们开始用它的时候起会不断地演进,而以一个更加结构化的方式重新创建它的话,从成本上来说是不值得的。

    问题

    有一台虚拟机,想要将其转换成一个Docker镜像。

    解决方案

    为自己的虚拟机文件系统创建一个TAR文件,可以使用qemu-nbd、通过ssh执行tar命令或者其他方法,然后在Dockerfile里使用ADD命令加入TAR文件以创建自己的镜像。

    讨论

    首先我们将虚拟机划分为两大类:本地(虚拟机磁盘镜像放在本地,虚拟机的执行操作发生在用户的计算机上)以及远程(虚拟机磁盘镜像存储在远程,虚拟机的执行操作发生在其他地方)。

    这两类虚拟机(以及其他任何用户想为之创建Docker镜像的虚拟机)在原则上是一致的——需要拿到整个文件系统的TAR,然后用ADD命令将TAR文件加到scratch镜像的/

    ADD命令 在镜像中拥有ADD命令时,Dockerfile的ADD命令(不像它的兄弟命令COPY)会自动将TAR文件(gzip压缩过的文件以及其他一些类似的文件类型也是如此)解压出来。

    scratch镜像 scratch镜像是一个零字节虚拟镜像,可以基于此构建其他镜像。一般来说,它适用于想使用一个Dockerfile复制(或者添加)一个完整文件系统的情况。

    现在,让我们先来看看用户有一个本地Virtualbox虚拟机的情况。

    在开始之前,首先需要完成下列任务:

    • 安装qemu-nbd工具(在Ubuntu上是作为qemu-utils包的一部分提供出来的);
    • 定义虚拟机的磁盘镜像的路径;
    • 关闭虚拟机。

    如果用户的虚拟机磁盘镜像是.vdi或者.vmdk格式的话,这一技巧应该会很奏效。其他格式则可能成败参半。

    下列代码展示了该如何将用户的虚拟机文件转成一个虚拟磁盘,这样一来他也就可以从这里复制出所有文件:

    1. $ VMDISK=”$HOME/VirtualBox VMs/myvm/myvm.vdi 1
    2. $ sudo modprobe nbd 2)(3
    3. $ sudo qemu-nbd -c /dev/nbd0 -r $VMDISK 4
    4. $ ls /dev/nbd0p
    5. /dev/nbd0p1 /dev/nbd0p2
    6. $ sudo mount /dev/nbd0p2 /mnt 5
    7. $ sudo tar cf img.tar -C /mnt . 6
    8. $ sudo umount /mnt && sudo qemu-nbd -d /dev/nbd0 7

    (1)设置一个环境变量指向用户的虚拟机磁盘镜像

    (2)初始化一个qemu-nbd需要的内核模块

    (3)列出这块磁盘上可以挂载的分区号

    (4)将虚拟机的磁盘连接到一个虚拟设备节点

    (5)通过qemu- nbd把选择的分区挂载到/mnt

    (6)从/mnt创建出一个名为img.tar的TAR文件

    (7)卸载分区然后用qemu-nbd清理

    选择一个分区 要选择挂载的分区的话,可以运行sudo cfdisk /dev/nbd0来查看可选项。注意,如果看到了LVM选项,也就意味着磁盘采用的不是普通的分区方案,关于如何挂载LVM分区,用户将需要做一些额外的调研。

    如果是远程虚拟机,用户需要作出一个选择:要么关掉虚拟机然后让运维团队介入,转储分区文件,要么在虚拟机是运行的状态下为它创建出一个TAR。

    如果用户拿到的是分区转储文件,就可以很轻松地进行挂载,然后按照如下命令把它转换成一个TAR文件:

    1. $ sudo mount -o loop partition.dump /mnt
    2. $ sudo tar cf $(pwd)/img.tar -C /mnt .
    3. $ sudo umount /mnt

    另外,也可以选择从一个正在运行的系统创建出TAR文件。在登录到系统后可以轻松实现这一点:

    1. $ cd /
    2. $ sudo tar cf /img.tar exclude=/img.tar —one-file-system /

    至此,用户拿到了文件系统镜像的TAR,紧接着可以通过scp把它发送到其他机器。

    状态中断的风险 从一个正在运行的系统创建出TAR看上去可能是最简单的方案(没有关机,不需要安装软件或者请求别的团队),但是它也存在一些弊端——用户复制出来的文件可能存在状态不一致的情况,并且可能会在试用制作出来的新的Docker镜像时遇到一些莫名其妙的问题。如果只能这样做的话,那就尽可能多地停掉一些应用和服务。

    一旦拿到了文件系统的TAR,便可以将其加到镜像里。这一过程再简单不过了,它是由一个两行代码的Dockerfile组成的:

    1. FROM scratch
    2. ADD img.tar /

    现在可以执行docker build .,然后便能得到自己的镜像了!

    从TAR导入 除了ADD,Docker还提供了一个替代的docker import形式的命令,可以使用cat img.tar | docker import - new_image_name来导入文件。然而,即便选择在构建镜像时附带一些额外的指令,用户仍然始终需要创建一个Dockerfile。因此,使用ADD命令可能会更简单一些,也可以轻松地看到镜像的历史。

    现在,因为在Docker里已经有了一个镜像,就可以开始用它来做一些实验了。在本例中,用户可能会从基于新镜像创建出一个新的Dockerfile开始,通过剥离文件和软件包来实验。

    一旦完成了这一点并且得到了满意的结果,紧接着便可以在运行中的容器上用docker export导出一个新的、更小巧的TAR,用户可以把它用作新一层镜像的基础,然后重复这一过程直到对得到的镜像满意为止。

    图3-1中的流程图展示了这一过程。

    3

    图3-1 容器的“瘦身”过程

    现在,我们将继续讨论一个在Docker社区里颇具争议的领域——运行一个从一开始就运行着多个进程的类宿主机镜像。

    在Docker社区里,一部分人认为这是一个糟糕的做法。容器并不是虚拟机(有着显著差异),而且完全没办法假装说对此没有困惑或者问题。

    好也罢,坏也罢,这一技巧将会展示该如何运行一个类宿主机镜像,然后讨论围绕着这种做法所带来的一些问题。

    逐步引入 运行类宿主机镜像会是一个说服Docker反对派的好方法,告诉他们Docker是有用的。他们用得越多,对范式的理解就会越透彻,微服务方案对他们来说也就越有意义。在我们将Docker引入企业内部后,我们发现这种单体方式是一个很棒的切入点,它可以推动人们从以前的在开发服务器及笔记本上开发转换到一个更包容和可管理的环境。由此,将Docker推广到测试、持续集成、托管环境以及DevOps工作流也就水到渠成了。

    虚拟机和Docker容器之间的差异! 

    虚拟机和Docker容器之间存在以下一些不同之处。

    • Docker 是面向应用的,而虚拟机是面向操作系统的。
    • Docker容器和其他容器共享同一个操作系统。相反,每个虚拟机都有一个由hypervisor管理的它们自己的操作系统。
    • Docker容器被设计成只运行一个主要进程,而不是管理多组进程。

    问题

    想要自己的容器就像一个正常的宿主机环境,可以运行多个进程和服务。

    解决方案

    使用一个被设计成模拟一台宿主机的镜像,然后置备它时包含需要的应用程序。

    讨论

    这里我们打算使用phusion/baseimage Docker镜像,一个被设计成运行多个进程的镜像。

    第一步就是启动该镜像然后使用docker exec连到它里面:

    1. user@docker-host$ docker run -d phusion/baseimage 1
    2. 3c3f8e3fb05d795edf9d791969b21f7f73e99eb1926a6e3d5ed9e1e52d0b446e 2
    3. user@docker-host$ docker exec -i -t 3c3f8e3fb05d795 /bin/bash 3
    4. root@3c3f8e3fb05d:/# 4

    (1)❶

    (2)❷

    (3)❸

    (4)❹

    在上述代码中,docker run将会在后台启动该镜像❶,执行该镜像默认的启动命令,然后返回新创建的容器的ID❷。

    然后可以将这个容器的ID传给docker exec❸,该命令会在这个已经运行的容器内部启动一个新的进程。-i标志代表着可以和新进程交互,而-t则意味着想配置一个TTY,它允许在容器内部开启一个终端(/bin/bash)❹。

    如果等待1 min,然后查看进程表,输出内容如代码清单3-1所示。

    代码清单3-1 一个类宿主机容器里正在运行的进程

    1. root@aba74d81c088:/# ps ef 1)       
    2. UID  PID PPID  C STIME TTY   TIME CMD
    3. root  1   0  0 13:33 ?  00:00:00 /usr/bin/python3 -u /sbin/my_init 2
    4. root  7   0  0 13:33 ?  00:00:00 /bin/bash 3
    5. root 111   1  0 13:33 ?  00:00:00 /usr/bin/runsvdir -P /etc/service 4)  
    6. root 112  111  0 13:33 ?  00:00:00 runsv cron 5
    7. root 113  111  0 13:33 ?  00:00:00 runsv sshd
    8. root 114  111  0 13:33 ?  00:00:00 runsv syslog-ng
    9. root 115  112  0 13:33 ?  00:00:00 /usr/sbin/cron -f
    10. root 116  114  0 13:33 ?  00:00:00 syslog-ng -F -p
    11. /var/run/syslog-ng.pid no-caps
    12. root 117  113  0 13:33 ?  00:00:00 /usr/sbin/sshd -D
    13. root 125   7  0 13:38 ?  00:00:00 ps ef 6

    (1)执行ps命令列出所有正在运行的进程

    (2)一个简单的init进程,设计用来运行所有其他服务

    (3)bash进程由docker exec启动,并且当作shell使用

    (4)runsvdir运行所有在 /etc/service目录里定义的服务

    (5)通过runsv命令在这里启动3个标准服务(cron、 sshd和syslog)

    (6)当前执行的ps命令

    可以看到,容器的启动过程很像一台宿主机,初始化一些像cron和sshd这样的服务使它看上去和一台标准的Linux主机没什么两样。这作为对那些新入门的Docker工程师的最初演示时非常有用。

    至于这样做是否违反了微服务的“一个容器一个服务”的原则,在Docker社区里是一个见仁见智的问题。类宿主机镜像方案的支持者认为这样做并没有违背该原则,因为容器仍然可以满足为里面运行的系统提供单一的离散功能的需求。

    我们已经探讨了该如何把一个容器用作一个单体实体(像传统的服务器那样),并且阐明这会是一个快速将一个系统架构迁移到Docker上的好方法。然而,在Docker的世界里,公认的最佳实践是尽可能多地把系统拆分开,直到在每个容器上都只运行一个“服务”,并且所有容器都通过链接互相连通。由于这是Docker官方推荐的做法,因此读者会发现从Docker Hub上获取的绝大多数容器都遵循这个方案。而理解该如何以这种方式构建镜像,对与Docker生态系统的其他组件交互也尤为重要。

    使用一个容器一个服务的主要原因在于可以更容易通过单一职责原则(single responsibility principle)实现关注点分离(separation of concerns)[1]。如果用户的容器执行的是单一任务,那么可以很方便地把该容器应用到从开发、测试到生产的整个软件开发生命周期里,而无须太担心它与其他组件的交互问题。这就使软件项目可以更敏捷地交付并且具备更好的扩展性。但是,它的确带来了一些管理上的负担,因此,最好思量一下在自己的用例场景下这样做是否真的值得。

    暂且不论哪种方案更适合,最佳实践方法至少拥有一个明显的优势——正如所见,在使用Dockerfile时实验和重新构建都比前一套方案快上不少。

    问题

    想将应用程序拆分为各个单独的且更易于管理的服务。

    解决方案

    使用Docker将应用程序拆分并堆砌出多个基于容器的服务。

    讨论

    在Docker社区里,关于应当怎样严格遵守“一个服务一个容器”的规则方面还存在着一些争议,这其中部分源自在定义方面的一些异议——它是一个单个的进程,还是说可以是结合在一起共同满足一个需求的一组进程?最终,它往往会被归结为这样一种说法,即赋予从头开始重新设计系统的能力,微服务也许会是大多数人的选择。但是有时候实用主义可能会战胜理想主义——当为组织评估Docker时,为了能够让Docker尽可能更快、更容易地用起来,我们发现自己在当时的处境下只能选择单体这条路。

    让我们一起来看看在Docker内部运行单体应用的其中一个具体弊端。正如代码清单3-2中所列的那样,我们需要先展示的是如何构建一个拥有数据库、应用程序以及Web服务器的单体应用。

    简化版Dockerfile 这些例子只是用于教学目的,并且已经被相应简化。尝试直接运行它们不一定能正常工作。

    代码清单3-2 配置一个简单的PostgreSQL、NodeJS和Nginx应用

    1. FROM ubuntu:14.04
    2. RUN apt-get update && apt-get install postgresql nodejs npm nginx
    3. WORKDIR /opt
    4. COPY . /opt/
    5. RUN service postgresql start && \
    6.   cat db/schema.sql | psql && \
    7.   service postgresql stop
    8. RUN cd app && npm install
    9. RUN cp conf/mysite /etc/nginx/sites-available/ && \
    10.   cd /etc/nginx/sites-enabled && \
    11.   ln -s ../sites-available/mysite

    何时在RUN语句里使用命令链 在RUN语句里使用&&能够有效确保这些命令作为一条命令执行。这一点非常有用,因为它可以保持镜像不至于太大。每条Dockerfile命令都会在之前的基础上创建一个单一的新镜像层。如果以这种方式执行一个软件包更新的命令,如apt-get update这样的安装命令,用户便能够确保无论软件包是何时安装的,它们都将是来源于一个已经更新过的包缓存。

    前面的例子是一个简单的概念版的Dockerfile,它会在容器里安装一切需要的软件,并随后配置好数据库、应用程序和Web服务器。但是,如果想快速地重新构建容器的话就有问题了——仓库下的任何文件的任意改动均会造成一切事物从{}开始重新构建,因为这种情况下无法复用之前的缓存。如果存在一些执行较慢的步骤(数据库的创建或者npminstall)的话可能就得在容器重新构建的时候等待一段时间。

    针对这个问题的解决方案便是将COPY . /opt/指令拆到应用(数据库、应用程序和Web配置)的各个部分:

    1. FROM ubuntu:14.04
    2. RUN apt-get update && apt-get install postgresql nodejs npm nginx
    3. WORKDIR /opt
    4. COPY db /opt/db                    -+
    5. RUN service postgresql start && \          |- 1
    6.   cat db/schema.sql | psql && \          |
    7.   service postgresql stop             -+
    8. COPY app /opt/app                  -+
    9. RUN cd app && npm install              |- 2
    10. RUN cd app && ./minify_static.sh          -+
    11. COPY conf /opt/conf                 -+
    12. RUN cp conf/mysite /etc/nginx/sites-available/ && \ +
    13.   cd /etc/nginx/sites-enabled && \         |- 3
    14.   ln -s ../sites-available/mysite         -+

    (1)设置db

    (2)设置app

    (3)设置Web

    在前面的代码里,COPY命令被分成两个单独的指令。由于可以利用缓存复用在修改代码之前未经变更的交付文件,这就意味着数据库不会在每次代码更改的时候都重新构建。

    但是,由于缓存功能是相当简单粗糙的,容器仍然不得不在每次对schema脚本做出更改时完全地重新构建。解决这一问题的唯一途径便是抛弃原有顺序配置的步骤,创建多份Dockerfile,内容如代码清单3-3、代码清单3-4和代码清单3-5所示。

    代码清单3-3 数据库 Dockerfile

    1. FROM ubuntu:14.04
    2. RUN apt-get update && apt-get install postgresql
    3. WORKDIR /opt
    4. COPY db /opt/db
    5. RUN service postgresql start && \
    6.   cat db/schema.sql | psql && \
    7.   service postgresql stop

    代码清单3-4 应用程序 Dockerfile

    1. FROM ubuntu:14.04
    2. RUN apt-get update && apt-get install nodejs npm
    3. WORKDIR /opt
    4. COPY app /opt/app
    5. RUN cd app && npm install
    6. RUN cd app && ./minify_static.sh

    代码清单3-5 Web服务器 Dockerfile

    1. FROM ubuntu:14.04
    2. RUN apt-get update && apt-get install nginx
    3. WORKDIR /opt
    4. COPY conf /opt/conf
    5. RUN cp conf/mysite /etc/nginx/sites-available/ && \
    6.   cd /etc/nginx/sites-enabled && \
    7.   ln -s ../sites-available/mysite

    每当dbapp或者conf文件夹下的内容有一个发生变化,将会只有一个容器需要被重新构建。当有超过3个以上的容器又或者有一些对时间敏感的配置步骤时,这样做会特别有用——只要花上一些心思,在每个步骤中添加最低限度的所需文件,结果便是可以最大程度地利用Dockerfile的缓存机制。在应用的Dockerfile(代码清单3-4)里,npm install操作定义在了一个单独的文件package.json里,因此我们可以通过修改我们的Dockerfile以利用dockefile本身的镜像层缓存机制,并且只需要在必要的时候才去重新构建缓慢的npm install,如代码清单3-6所示。

    代码清单3-6 重新排序的Dockerfile,更早执行npm install

    1. FROM ubuntu:14.04
    2. RUN apt-get update && apt-get install nodejs npm
    3. WORKDIR /opt
    4. COPY app/package.json /opt/app/package.json
    5. RUN cd app && npm install
    6. COPY app /opt/app
    7. RUN cd app && ./minify_static.sh

    但是,天下没有免费的午餐——我们必须将一个简单的Dockefile转换为多个重复的Dockerfile。我们可以通过添加另外一个Dockerfile当作自己的基础镜像来解决部分问题,但是其他这样重复的情况并不少见。此外,现在启动镜像时又会冒出一些新的复杂性——除在EXPOSE步骤让一些合适的端口可以用于链接以及修改Postgres配置外,我们还需要确保在自己每次启动时链接到各个容器。幸运的是,已经有这样的一款工具,它叫作docker-compose(前身是fig),我们将会在技巧68里介绍它。

    Docker官方文档中清楚地表达了Docker容器并不是虚拟机。Docker容器和虚拟机之间一个关键的区别就是,容器是被设计成运行单个进程的。当该进程结束时,容器便会退出。这就是它和一台Linux虚拟机(或者任意一个Linux操作系统)的不同之处,它没有init进程。

    init进程在Linux操作系统上是以进程ID为1并且父进程ID为0的形式运行的。这个init进程可能会被叫作“init”或者“systemd”。无论它叫什么,它的职责都是承担运行在该操作系统上的所有其他进程的维护工作。

    如果开始实验Docker的话,用户可能会发现自己仍然有启动多个进程的需求。例如,用户可能会想要运行一些cron作业来收拾本地应用的日志文件,又或者在容器里配置一个内部的memcached服务器。如果选择走这条路的话,那么可能最终需要编写一个shell脚本来管理这些子进程的启动。实际上,用户将会效仿init进程的做法。别这么干!进程管理中的许多问题之前都已经被其他人遇到过了,并且已经在预打包系统里解决了。

    当尝试将Docker用作虚拟机的替代品时,选择在容器里运行多个服务可能会是一个方便之举,又或者说这可能是在最初将虚拟机转换成容器后要运行重要服务时的必要之举。

    无论原因是什么,重要的是要避免尝试在容器里管理进程时重复造轮子。

    问题

    想要在一个容器里管理多个进程。

    解决方案

    使用Supervisor应用(http://supervisord.org/)来管理进程的启动。

    讨论

    我们打算展示一下如何置备带有Tomcat和一个Apache Web服务器的容器,并且以Supervisor托管的方式启动并运行它。

    首先,如代码清单3-7所示,在一个新的空目录里创建Dockerfile。

    代码清单3-7 Supervisor示例Dockerfile

    1. FROM ubuntu:14.04 1
    2. RUN apt-get update && apt-get install -y python-pip apache2 tomcat7 2
    3. ENV DEBIAN_FRONTEND noninteractive 3
    4. RUN pip install supervisor 4
    5. RUN mkdir -p /var/lock/apache2 5
    6. RUN mkdir -p /var/run/apache2
    7. RUN mkdir -p /var/log/tomcat
    8. RUN echo_supervisord_conf > /etc/supervisord.conf 6
    9. ADD ./supervisord_add.conf /tmp/supervisord_add.conf 7
    10. RUN cat /tmp/supervisord_add.conf >> /etc/ 8
    11. supervisord.conf
    12. RUN rm /tmp/supervisord_add.conf 9
    13. CMD [“supervisord”,”-c”,”/etc/supervisord.conf”] 10

    (1)从ubuntu:14.04开始

    (2)安装python- pip(用来安装Supervisor)、apache2和tomcat7

    (3)设置一个环境变量,表明此会话是非交互式的

    (4)通过pip安装Supervisor

    (5)创建一些运行应用所需的维护目录

    (6)利用echo_sup- ervisord_conf工具创建一个默认的super- visord配置文件

    (7)将Apache和Tomcat的supervisord配置设定复制到镜像里,做好加到默认配置的准备

    (8)将Apache和Tomcat的supervisord配置设定追加到supervisord的配置文件里

    (9)由于不再有用处了,删除之前上传的文件

    (10)现在,只需要在容器启动时运行Supervisor即可

    还将需要配置Supervisor,指示它需要启动哪些应用,如代码清单3-8所示。

    代码清单3-8 supervisord_add.conf

    1. [supervisord] 1
    2. nodaemon=true 2
    3. # apache
    4. [program:apache2] 3
    5. command=/bin/bash -c source /etc/apache2/envvars && 4
    6. exec /usr/sbin/apache2 -DFOREGROUND
    7. # tomcat
    8. [program:tomcat]
    9. command=service start tomcat
    10. redirect_stderr=true
    11. stdout_logfile=/var/log/tomcat/supervisor.log 5
    12. stderr_logfile=/var/log/tomcat/supervisor.error_log

    (1)为supervisord声明全局配置块

    (2)设置成不要后台运行Supervisor进程,因为对容器来说它是一个前台进程

    (3)声明新程序的代码块

    (4)用于启动在该代码块中声明的程序的命令

    (5)配置相关日志

    由于用的是Dockerfile,因此可以借助标准的单个Docker命令来构建镜像:

    1. docker build -t supervised .

    现在可以运行构建好的镜像了!

    1. $ docker run -p 9000:80 name supervised supervised 1
    2. 2015-02-06 10:42:20,336 CRIT Supervisor running as root (no user in config file) 2)  
    3. 2015-02-06 10:42:20,344 INFO RPC interface supervisor initialized
    4. 2015-02-06 10:42:20,344 CRIT Server unix_http_server running
    5. without any HTTP authentication checking
    6. 2015-02-06 10:42:20,344 INFO supervisord started with pid 1 3)       
    7. 2015-02-06 10:42:21,346 INFO spawned: tomcat with pid 12
    8. 2015-02-06 10:42:21,348 INFO spawned: apache2 with pid 13
    9. 2015-02-06 10:42:21,368 INFO reaped unknown pid 29
    10. 2015-02-06 10:42:21,403 INFO reaped unknown pid 30
    11. 2015-02-06 10:42:22,404 INFO success: tomcat entered RUNNING state, 4
    12. process has stayed up for > than 1 seconds (startsecs)
    13. 2015-02-06 10:42:22,404 INFO success: apache2 entered RUNNING state,
    14. process has stayed up for > than 1 seconds (startsecs)

    (1)将容器的80端口映射到宿主机上的9000端口,给容器分配一个名字,然后指定要运行的镜像名称,即之前构建命令标记的那个

    (2)启动Supervisor进程

    (3)启动被托管的进程

    (4)被托管的进程被Supervisor识别为已经成功启动

    如果访问http://localhost:9000,应该就能看到启动的Apache服务器的默认页面。

    要清理容器,可以执行如下命令:

    1. docker rm -f supervised

    如果对Supervisor的其他替代品有兴趣,还有一个runit,技巧11里介绍过的Phusion基础镜像用到了它。

    有些人说代码直到提交到了源代码管理中才算编写完成,对容器来说又何尝不是这样。如果使用虚拟机可以借助快照来保存现有状态,但是Docker采取的是一个更为积极的方案,鼓励保存和复用已有的工作成果。

    我们将介绍在开发中“保存游戏”的方式、打标签(tagging)技术的一些具体细节、Docker Hub的使用,以及如何在构建时指向特定镜像。由于这些操作被认为是非常基础的,因而Docker将它们打造得相对简单和快捷。

    尽管如此,对于Docker新手来说这仍然是一个令人困惑的主题,因此在这一节中,我们将带领读者一步步更加全面地了解与这个主题相关的内容。

    如果曾经开发过任意类型的软件,那么你应该不止一次抱怨过:“我敢肯定在此之前它运行得好好的!”也许原话还没有这么淡定。由于无法将系统还原到一个已知的良好(或者也许只有“更好”)状态,就只能赶紧强行修改(hack)代码以赶上最后期限或者修复漏洞,这也是许多人敲碎键盘累死累活的原因。

    源代码管理极大地改善了这一点,然而在特殊情况下还是存在以下两个问题:

    • 源代码仓库可能无法反映出“工作”环境下文件系统的状态;
    • 暂时还不想把代码提交上去。

    第一个问题比第二个更明显一些。尽管像Git这样的现代化源代码管理工具可以轻松地创建出一次性的本地分支,但是抓取整个开发环境文件系统的状态并不是源代码管理的初衷。

    Docker通过它的提交(commit)功能提供了一个廉价、快捷的方式来保存容器的开发环境文件系统状态,而这正是接下来要探讨的内容。

    问题

    想要保存开发环境的状态。

    解决方案

    使用docker commit来保存状态。

    讨论

    我们不妨假设用户想要修改第1章里的to-do应用。ToDo公司的CEO对这个应用不是很满意,并且想把浏览器上显示的标题从“Swarm+React - TodoMVC”改为“ToDoCorp’s ToDo App”。

    用户拿不准要怎么实现这一点,也许会想要先把应用运行起来,然后通过修改文件来进行实验,看看到底会发生什么:

    1. $ docker run -d -p 8000:8000 name todobug1 dockerinpractice/todoapp 1
    2. 3c3d5d3ffd70d17e7e47e90801af7d12d6fc0b8b14a8b33131fc708423ee4372
    3. $ docker exec -i -t todobug1 /bin/bash 2

    (1)❶

    (2)❷

    上述docker run命令 ❶ 在一个容器里以后台模式(-d)启动了to-do应用,将容器的8000端口映射到了宿主机上的8000端口(-p 8000:8000),为方便引用起见,将其命名为todobug1(—name todobug1),然后返回了该容器的ID。该容器启动时执行的命令默认会是我们构建的dockerinpractice/todoapp镜像在构建时指定的命令,该镜像也可以在Docker Hub上找到。

    第二条命令❷ 将会在正在运行的容器里启动/bin/bash。这里用到的是名为todobug1的容器,不过用户也可以使用其原本的容器ID。-i意味着这条exec命令以交互模式运行,而-t确保exec将会按照一个终端预期的那样工作。

    如今我们已经在容器里了,那么,实验的第一步便是安装一个编辑器。我们更喜欢vim,所以采用了如下命令:

    1. apt-get update
    2. apt-get install vim

    小经波折之后我们意识到需要修改的文件是local.html。因此,我们将该文件的第5行改成了如下内容:

    1. <title>ToDoCorp’s ToDo App</title>

    但CEO的意思可能是希望标题都是小写的,因为她听说这样看上去会更时尚些。这两种方式我们都想准备好,所以我们选择先提交现有成果。在另外一个终端下运行如下命令:

    1. $ docker commit todobug1 1
    2. ca76b45144f2cb31fda6a31e55f784c93df8c9d4c96bbeacd73cad9cd55d2970 2

    (1)把之前创建出来的容器转成镜像

    (2)刚提交的容器的新镜像ID

    如今已经将容器提交成镜像,并且可以在之后运行它。

    状态是无法捕获的! 提交一个容器只会保存在提交那个时刻容器的文件系统的状态,而不是进程。记住,Docker容器不是虚拟机。如果环境的状态依赖于一些正在运行的进程的状态,而这些进程不能通过标准文件恢复的话,这个技巧将无法帮助用户保存所需的那个状态。在这种情况下,用户也许得想办法看怎样才能恢复开发环境里进程的状态。

    紧接着,将local.html的内容修改成另外一个可能要求的值:

    1. <title>todocorp’s todo app</title>

    然后再次提交:

    1. $ docker commit todobug1
    2. 071f6a36c23a19801285b82eafc99333c76f63ea0aa0b44902c6bae482a6e036

    现在拥有两个镜像的ID(在这个演示里是ca76b45144f2cb31fda6a31e55f784c93df8c9d4c96bbeac d73cad9cd55d2970和071f6a36c23a19801285b82eafc99333c76f63ea0aa0b44902c6bae482a6e036,但是读者的可能会不一样),代表两种选择。当CEO来评估其想要的方案时,可以把两个镜像都运行起来,然后让她决定要提交哪一个。

    可以通过打开新的终端然后执行如下命令来实现这一点:

    1. $ docker run -p 8001:8000 \
    2. ca76b45144f2cb31fda6a31e55f784c93df8c9d4c96bbeacd73cad9cd55d2970 1
    3. $ docker run -p 8002:8000 \
    4. 071f6a36c23a19801285b82eafc99333c76f63ea0aa0b44902c6bae482a6e036 2

    (1)将容器的8000端口映射到宿主机的8001端口,然后指定小写的那个镜像的ID

    (2)将容器的8000端口映射到宿主机的8002端口,然后指定大写的那个镜像的ID

    这样一来,便可以在http://local-host:8001上展示大写的方案,在http://localhost:8002上呈现小写的方案。

    毋庸置疑,比起一长串随机的字符串,肯定还有更好的办法来引用镜像。下面一个技巧将会关注如何给这些镜像指定一个名称,以便能够更加轻松地引用它们。

    我们发现,当我们需要通过一系列棘手的命令来配置应用时这会是一个很有用的技巧。提交该容器时,一旦成功的话,它会记录我们的bash会话的历史,这也意味着通过一系列步骤重新恢复系统的状态成为可能。这可以节省大量的时间!而且,这在正实验一个新功能而又不太确定是否完工,或者当重现出一个漏洞然后想尽量确保能够回到那个挂掉的状态时,也很有用处。

    外部依赖是无法捕获的! 对容器的任何外部的依赖(如数据库、Docker卷或者其他被调用的服务)在提交时均不会被保存。当前介绍的这个技巧没有任何外部的依赖,因此我们也无须担心这一点。

    现在,用户已经通过提交容器保存了容器的状态,并且还得到了一个代表镜像ID的随机字符串。很显然,记住和管理这些包含大量数字的镜像ID是非常困难的。如果能够利用Docker的打标签功能给这些镜像赋予一些可读的名称(和标签)的话那就太给力了,它还能提醒用户为什么创建这些镜像。

    掌握这一技巧可以对镜像的用途一目了然,使机器上的镜像管理变得简单很多。

    问题

    想要方便地引用并且保存一次Docker提交。

    解决方案

    使用docker tag给这次提交命名。

    讨论

    打标签功能的基本用法是非常简单的:

    1. $ docker tag \ 1)  
    2.  071f6a36c23a19801285b82eafc99333c76f63ea0aa0b44902c6bae482a6e036 \ 2)     
    3.  imagename 3

    (1)docker tag命令

    (2)想要命名的镜像ID

    (3)想给镜像起的名字

    上述操作就是给镜像命名,可以用这个名字来引用该镜像,例如:

    1. docker run imagename

    这可比记住大量包含字母和数字的随机字符串轻松多了!

    如果想和别人分享镜像,除了设置标签还有其他事情要做。遗憾的是,与标签相关的术语可以说相当混乱。像镜像(image)、名称(name)和仓库(repository)这些术语在使用时很容易混淆。表3-1给出了这些术语的一些定义。

    表3-1 Docker标签的术语

    术  语

    含  义

    镜像

    一个只读层

    名称

    镜像的名字,如“todoapp”

    标签

    作为动词的话,它指的是给一个镜像起名字。作为名词的话,它是镜像名的一个修饰词

    仓库

    一组托管的打好标签的镜像,它们一起为容器创建相应的文件系统

    在这个表里面最容易混淆的术语恐怕还是镜像和仓库。一直以来我们使用的术语镜像其实可以简单地理解成是一组我们从一个容器划分出来的多个分层,但是从技术上来说,一个镜像即是一个递归地指向它的父镜像层的分层。而仓库则是受托管的,这意味着它会被存放在某个地方(可以是在Docker守护进程,也可以是在一个注册中心)。此外,仓库是一组打好标签的镜像,它们共同组成了容器的文件系统。

    这里用Git来进行类比可能有助于理解。当克隆一个Git仓库时,可以签出(check out)所要求的那个点的文件的状态。这一点可以与镜像类比。该仓库保存了每一次提交时文件的整个历史,可以据此追溯到最初的那次提交。因此,在最上面一层签出仓库时,其他的分层(或者提交)就都在克隆的那个仓库里了。

    在实践中,镜像仓库这两个术语或多或少有些混用,因此不必太担心这一点。但要注意的是,这些术语都是存在的,并且用起来也的确很相似。

    到目前为止所看到的内容都是如何给一个镜像ID命名的。令人不解的是,这个名字并不是该镜像的标签,虽说人们经常这样提到它。我们可以根据打标签(动词)和给镜像起名字的那个标签(名词)的行为来区分二者。这个标签(名词)允许用户给镜像的一个特定版本命名。用户可以通过添加标签来管理同一镜像不同版本的引用。例如,可以把一个版本名称或者提交日期当作标签。

    带有多个标签的仓库的一个很好的例子便是Ubuntu镜像。如果拉取Ubuntu镜像,然后运行docker images,就会得到与代码清单清单3-9所示类似的输出结果。

    代码清单3-9 带有多个标签的镜像

    1. $ docker images
    2. REPOSITORY TAG   IMAGE ID   CREATED   VIRTUAL SIZE
    3. ubuntu   trusty  8eaa4ff06b53 4 weeks ago 192.7 MB
    4. ubuntu   14.04  8eaa4ff06b53 4 weeks ago 192.7 MB
    5. ubuntu   14.04.1 8eaa4ff06b53 4 weeks ago 192.7 MB
    6. ubuntu   latest  8eaa4ff06b53 4 weeks ago 192.7 MB

    上面REPOSITORY一列列出了一组托管的叫作“ubuntu”的分层。通常这指的便是镜像。这里的TAG一列列出了4个不同的名称(trusty、14.04、14.04.1和latest)。IMAGE ID一列列出了几个完全一致的镜像ID。这是因为这些打上不同标签的镜像都是同一个。

    这表明用户可以拥有一个带有多个标签的相同镜像ID的仓库。尽管从理论上讲这些标签以后也可以指向不同的镜像ID。例如,如果“trusty”有一个安全更新的话,维护人员一次新的提交可能就会改变镜像ID,然后打上新的“trusty”“14.04.2”“latest”标签。

    如果没有指定标签的话默认会给镜像打上一个“latest”标签。

    “latest”标签的含义 “latest”标签在Docker里并没有什么特殊的含义——它只是在打标签和拉取镜像时的一个默认值。这并不意味着它是这个镜像设定的最后一个标签。镜像的“latest”标签也可能指向的是该镜像的一个老版本,因为这之后构建的版本可能会被打上一个特定的标签,如“v1.2.3”。

    如果能和其他人分享这些名字(和镜像)的话,给镜像打标签的时候用一些带描述的名字可能会更有帮助。为了满足这一需求,Docker提供了轻松将镜像迁移到其他地方的能力,且Docker公司也创建了Docker Hub这一免费服务以鼓励这样的分享。

    Docker Hub所需的账号 要利用本技巧的话,用户将需要一个Docker Hub账号,并且它已经在宿主机上通过运行docker login登录过。如果还没有配置这样的一个账号,可以到http://hub.docker.com上面创建一个。只需要照着注册说明的指示去做即可。

    问题

    想要公开分享一个Docker镜像。

    解决方案

    使用Docker Hub注册中心(registry)来分享镜像。

    讨论

    当讨论标签时,注册中心相关的各种术语可能容易让人混淆。表3-2应该有助于理解这些术语该如何使用。

    表3-2 Docker注册中心的术语

    术  语

    含  义

    用户名(user name)

    Docker注册中心上的用户名

    注册中心(registry)

    注册中心持有镜像。一个注册中心就是一个可以上传镜像或者从这里下载镜像的存储。注册中心可以是公开的,也可以是私有的

    注册中心宿主机(registry host)

    运行Docker注册中心的宿主机

    Docker Hub

    托管在https://hub.docker.com上公开默认的注册中心

    索引(index)

    与注册中心宿主机含义相同,这似乎是一个过时的术语

    正如之前所见,只要喜欢,用户可以给一个镜像打上多个标签。这对复制过来的镜像很有用,如此一来用户便拥有了它的控制权。

    例如,假设用户在Docker Hub上的用户名是adev。代码清单3-10中的3条命令展示了怎样从Docker Hub上将debian:wheezy镜像复制到自己的账号下。

    代码清单3-10 将一个公用镜像复制和推送到adev的Docker Hub账号

    1. docker pull debian:wheezy 1
    2. docker tag debian:wheezy adev/debian:mywheezy1 2
    3. docker push adev/debian:mywheezy1 3

    (1)从Docker Hub上拉取debian镜像

    (2)给wheezy镜像打上自己的用户名(adev)并且指定一个标签(mywheezy1)

    (3)推送新创建的标签

    至此,用户已经得到了一个下载好的Debian wheezy镜像的引用,可以维护、关联或者以它为基础构建其他镜像。

    如果有可以推送的私有仓库,除了必须在标签的前面指定注册中心的地址外,其他流程是完全一致的。假设有一个仓库放在http://mycorp.private.dockerregistry。代码清单3-11中列出的命令将会为镜像打上标签然后推送到该注册中心。

    代码清单3-11 将一个公用镜像复制并推送到adev的私有注册中心

    1. docker pull debian 1
    2. docker tag debian:wheezy \
    3. mycorp.private.dockerregistry/adev/debian:mywheezy1 2
    4. docker push mycorp.private.dockerregistry/adev/debian:mywheezy1 3

    (1)从Docker Hub上拉取Debian镜像

    (2)用注册中心(mycorp.private.dockerregistry)、用户名(adev)和标签(mywheezy1)给wheezy镜像打上标签

    (3)将新创建的标签推送到私有注册中心。需要注意的是,在打标签和推送时都必须指定私有注册中心服务器的地址,这样一来Docker才能确保把它推送到正确的位置

    上述命令将不会把镜像推送到公有的Docker Hub上,而是会把它推送到私有的仓库里,因此任何人只要可以访问该服务上的资源就能够拉取它。

    至此,用户已经具备了和其他人分享镜像的能力。这是一个同其他工程师分享工作成果、想法甚至于遇到的一些问题的绝佳办法。

    在构建过程中绝大部分时间里所引用的将是一些通用的镜像名,如“node”或者“ubuntu”,而且这些用起来可能不会有问题。

    如果要引用一个镜像名的话,该镜像有可能会在标签保持不变的情况下发生变化。尽管听起来很荒谬,但是的确是这样的!仓库的名字只是一个引用,而它所指向的底层镜像可能会变成不同的。用冒号指定一个标签(如ubuntu:trusty)也没办法消除这一风险,因为像一些安全更新就可以用相同的标签自动地重新构建易受攻击的镜像。

    绝大多数时候用户可能是希望这样的——镜像的维护人员可能找到了一个改进以及修补安全漏洞的方法,这通常是一件好事。不过有时候这也会是一个痛点。而这不只是一个理论上的风险:在一些场合下已经发生了这样的事情,它以一种难以调试的方式破坏了持续交付的构建。在使用Docker的初期,那些最受欢迎的镜像会定期地添加和删除软件包(这里面还包括一个令人难忘的回忆,passwd命令居然消失了!),造成之前还正常工作的构建突然崩掉。

    问题

    想要确保是从一个特定的未做更改的镜像构建。

    解决方案

    从一个特定的镜像ID构建。

    讨论

    当想要绝对地确定构建时使用的是给定的文件时,可以在Dockerfile里指定一个特定的镜像ID。

    下面是一个例子(可能在读者的环境里无法正常工作):

    1. FROM 8eaa4ff06b53 1
    2. RUN echo Built from image id:” > /etc/buildinfo 2
    3. RUN echo 8eaa4ff06b53 >> /etc/buildinfo
    4. RUN echo an ubuntu 14.4.01 image >> /etc/buildinfo
    5. CMD [“echo”,”/etc/buildinfo”] 3

    (1)从一个指定的镜像(或者分层)ID构建

    (2)在这个镜像里运行一个命令,把构建时引用的镜像记录到新镜像的一个文件里

    (3)构建的镜像默认会输出记录到/etc/buildinfo文件里的信息

    要像这样从一个特定的镜像(或者分层)ID构建的话,镜像ID就必须存储到Docker守护进程本地。Docker注册中心将不会执行任何类型的查找操作来找出Docker Hub上可用镜像的各个分层的镜像ID,也不会在可能配置使用的任何其他注册中心上这样做。

    值得一提的是,用户指向的那个镜像是不需要打过标签的——它可以是本地的任意一个镜像分层。用户可以从希望的任何分层开始构建。这在做某些调查或者一些实验性步骤而想要完成Dockerfile的构建分析时也许有用。

    如果想远程持久化镜像,那么最好是给该镜像打上标签,然后将它推送到远程注册中心里一个受控制的仓库下。

    Docker镜像可能会停止工作 值得指出的是,绝大部分要面对的问题都发生在一个之前还工作的Docker镜像突然间就不工作了。通常这是因为一些东西在网络层面有所变动。这其中一个记忆犹新的例子便是,某个早上我们的构建因为apt-get update失败。我们假定这是本地deb缓存的问题,然后尝试调试但是没有成功,直到一位可爱的系统管理员指出我们正在构建的这个特定版本的Ubuntu已经不再被支持了。这意味着apt-get update的网络调用都在返回HTTP错误。

    看待Docker的视角之一是把它看作将环境变成各个进程的工具。此外,虽说虚拟机同样也可以这样对待,但Docker使这件事情变得更加便捷、高效。

    为了说明这一点,我们将展示应该怎样加速启动、存储和重建容器状态,这可以做成一些以其他方式(几乎)很难办到的事情——在2048上夺冠!

    本技巧旨在提供一点儿轻松的调味剂,展示Docker可以怎样用来轻松地恢复状态。如果对2048不是很熟悉的话,不妨把它看作是一个容易上瘾的在板上推数字的游戏。如果想先熟悉一下它,在http://gabrielecirulli.github.io/2048有一个在线的最初版本。

    问题

    为了能够在需要的时候恢复到一个已知的状态,想要定期保存容器的状态。

    解决方案

    当不确定是否可以活下来时使用docker commit来“保存游戏”。

    讨论

    我们在此之前已经创建了一个单体镜像,用户可以在一个拥有VNC服务器以及Firefox的Docker容器里玩2048。

    要使用这个镜像的话,用户需要安装一个VNC客户端。热门的实现方案有TigerVNC和VNC Viewer等。如果一个都没有的话,那么在宿主机上的包管理器里快速搜索关键字“vnc client”应该也能得到有用的结果。

    要启动容器,可以执行代码清单3-12中列出的命令。

    代码清单3-12 启动2048容器

    1. $ docker run -d -p 5901:5901 -p 6080:6080 name win2048 \
    2.  imiell/win2048 1
    3. $ vncviewer localhost:1 2

    (1)❶

    (2)❷

    先从imiell/win2048镜像运行一个容器,这一步我们已经准备好了❶。我们在后台启动这个容器,然后指定它应该给宿主机开放两个端口(5901和6080)。在容器内部自动启动的VNC服务器将会使用这些端口,还给容器起了一个以后易于使用的名字——win2048

    现在可以运行VNC Viewer了(根据安装的情况可执行文件可能会不同),然后指示它连接到本地计算机❷。因为相应的端口已经从容器里暴露出来,连接到本地主机实际上也就是连接到容器。如果宿主机上除了一个标准的桌面外没有X显示,那么localhost后面的:1便是合理的——要是有的话,用户可能就得选择一个不同的数字,然后查阅下VNC Viewer的文档,将VNC端口手动指定为5901

    一旦连上了VNC服务器,它就会提示输入密码。这个镜像的VNC密码是vncpass

    我们会看到一个带有Firefox标签页的窗口和一个预先加载的2048的表格。点击它以获取焦点,然后玩到准备好保存游戏为止。

    要保存游戏的话,得在提交它之后给这个命名好的容器打上一个标签:

    1. $ docker commit win2048 1)  
    2. 4ba15c8d337a0a4648884c691919b29891cbbe26cb709c0fde74db832a942083 2
    3. $ docker tag 4ba15c8d337 my2048tag:$(date +%s)

    (1)❶

    (2)❷

    在提交win2048容器❶后可以生成一个镜像ID❷,现在想给它赋予一个唯一的名字(因为可能创建了一堆这样的镜像)。为了做到这一点,我们将利用date +%s命令的输出作为镜像名称的一部分,该命令会输出一串从1970年的第一天开始算起的总秒数,提供一个唯一的(我们的目的)、不断增长的值。$(command)语法只是在该位置将内容替换为整个命令的输出。如果愿意的话,也可以手动执行date +%s,取而代之的是,粘贴输出作为镜像名称的一部分。

    然后可以继续玩下去,直到输了为止。现在该表演魔术了!我们可以通过代码清单3-13所示的命令返回到存档点。

    代码清单3-13 返回到之前的游戏存档

    1. $ docker rm -f win2048
    2. $ docker run -d -p 5901:5901 -p 6080:6080 name win2048 my2048tag:$mytag

    $mytag是在docker images命令里选出的一个标签。重复打标签、删除、运行这几个步骤,直到完成2048为止。

    在本章中,读者已经看到了在许多使用场景中Docker是怎样取代虚拟机的。虽然Docker并不是一个虚拟机技术,而且虚拟机和容器之间也有重大差别,但是Docker带来的便利的确可以加快开发过程。

    本章在前面两章介绍的Docker架构的基础上,展示了它能够为我们的工作带来多大的便利。

    我们主要讨论了以下几点:

    • 最开始迁移到Docker时选择将虚拟机转换成Docker镜像会相对轻松些;
    • 可以在容器里管理服务,像以前虚拟机一样操作;
    • 提交是一种正确的随手保存工作的方式;
    • 可以给镜像命名,然后在Docker Hub上免费向世界分享它们。

    采用新的模式开发常常会新增一些日常琐事和考验,在下一章里我们将探讨这其中一些较为重要的部分。


    [1]  这是两个计算机领域的术语,具体见https://en.wikipedia.org/wiki/Separation_of_concerns和https://en.wikipedia.org/ wiki/Single_responsibility_principle。——译者注