轻松上手Docker,让博客Docker化

jopen 9年前

Docker是一个有趣的技术,在过去的两年已经从一个变成了全世界的机构都在使用来部署应用的技术。在今天的文章中我将会讨论如何通过Docker化一个现有应用来上手Docker。那这里选取的应用就是我的博客。

什么是Docker

在我们开始学习Docker的基础之前让我们首先理解什么是Docker并且为什么其那么流行。Docker是一个操作系统容器管理工具,通过将他们打包进一个操作系统容器中,能让你轻松管理和部署应用。

容器 vs. 虚拟机

容器可能部署虚拟机一样为人所熟悉,但是他们是另外一个方法来提供 操作系统虚拟化 的方法。然而,他们与标准的虚拟机有很大的差异。

标准的虚拟机通常包含一个完整的操作系统,操作系统软件包,最后包含一两个应用。这是通过一个向虚拟机提供了硬件虚拟化Hypervisor里达成的。这允许一单个服务器运行很多独立的操作系统,以虚拟游客的身份。

容器与虚拟机类似之处在于他们允许一个单个服务器来运行多个操作系统的环境,这些环境然而不是完整的操作系统。容器通常只包含必要的OS软件包和应用。他们通常不包含一个完整的操作系统或者硬件虚拟化。这也意味着相比虚拟机,容器的额外开销更小。

容器和虚拟机通常被视为互相通途的技术,然而这通常是一个误解。虚拟即使一个方法让一个物理的服务器提供完全功能运转的操作系统环境可以和其他虚拟机一起共享这些物理资源。一个容器通常是用来对单一主机的一个进程进行隔离来保证隔离的进程无法与处于同一个系统的其他进程进行互动。实际上,比起完全的虚拟机,容器与BSD的Jail,chroot过的进程更加类似。

在容器的基础上Docker提供了什么

Docker自身不是一个容器的运行时环境;实际上Docker实际上是一种容器技术,类似于Solaris Zones和BSD Jails。Docker提供的是一个管理,打包和部署容器的方法。尽管这些类型的功能已经某一种成都的存在于虚拟机中,他们传统上并不是为了绝多数的容器方案而存在的,而存在却不如Docker一样容易使用且功能完善。

现在我们知道了Docker是什么,让我们开始通过安装Docker并且部署一个公共的预先构建好的容器来学习Docker是怎么工作的。

从安装开始

因为Docker不会默认安装好,第一步就是安装Docker软件包;因为我们实例中使用额操作系统是Ubuntu 14.04,我们将会使用Apt包管理工具:

# apt-get install docker.io    Reading package lists... Done    Building dependency tree           Reading state information... Done    The following extra packages will be installed:    aufs-tools cgroup-lite git git-man liberror-perl    Suggested packages:    btrfs-tools debootstrap lxc rinse git-daemon-run git-daemon-sysvinit git-doc    git-el git-email git-gui gitk gitweb git-arch git-bzr git-cvs git-mediawiki    git-svn    The following NEW packages will be installed:    aufs-tools cgroup-lite docker.io git git-man liberror-perl    0 upgraded, 6 newly installed, 0 to remove and 0 not upgraded.    Need to get 7,553 kB of archives.    After this operation, 46.6 MB of additional disk space will be used.    Do you want to continue? [Y/n] y

要检查是否有容器运行我们可以执行 docker 命令,然后使用 ps 选项:

docker ps

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

docker 命令的 ps 功能类似于Linux的 ps 命令。它将会显示可用的Dokcer容器和他们的当前状态。应为我们没有启动任何的Docker容器,命令显示没有任何的正在运行的容器。

部署一个预构建好的nginx Docker容器

我最喜欢的一个Docker特性其可有让你用 yum 或者 apt-get 部署一个软件包一样的方式来部署一个预先构建好容器的能力。为了更好的说明这一点,让我们来部署一个预先构建好的运行nginx服务器的容器。我们可以通过执行docker命令,但是这一次,我们使用的是 run 选项。

docker run -d nginx

Unable to find image 'nginx' locally

Pulling repository nginx

5c82215b03d1: Download complete 

e2a4fb18da48: Download complete 

58016a5acc80: Download complete 

657abfa43d82: Download complete 

dcb2fe003d16: Download complete 

c79a417d7c6f: Download complete 

abb90243122c: Download complete 

d6137c9e2964: Download complete 

85e566ddc7ef: Download complete 

69f100eb42b5: Download complete 

cd720b803060: Download complete 

7cc81e9a118a: Download complete 

docker 命令的 run 功能告诉Docker来找一个指定的Docker镜像,并且启动一个运行该镜像的容器。默认情况下,Docker容器会在前台运行,意味这当你执行 docker run 你的shell会绑定到这个容器的console和在容器里面运行的进程。为了在将这个Docker容器在后台启动,我包含了一个 -d (detach,脱离)的标记。

现在再次运行 docker ps ,我们可以看到正在运行的nginx容器:

docker ps

CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS              PORTS            NAMES

f6d31ab01fc9        nginx:latest        nginx -g 'daemon off   4 seconds ago       Up 3 seconds        443/tcp, 80/tcp     desperate_lalande 

在上面的输出中,我们可以看到运行中的容器 desperate_lalande ,并且该容器是从镜像 nginx:latest 构建而来。

Docker镜像

镜像是Docker的核心特性之一,并且与虚拟机的镜像很类似。跟虚拟机的镜像类似的是,一个Docker镜像是一个保存好并且打包好的容器。然而Docker,并不止步于镜像创建。Docker也包含了通过Docker仓库分发这些镜像的能力,这个概念与软件包仓库类似。正是这给了Docker可以如同用yum来部署一个软件包一样来部署容器的能力。为了更好的理解这如何工作的,让我们再看看docker run的输出:

docker run -d nginx

Unable to find image 'nginx' locally

第一条消息我们可以看到 docker 不能在本地找到一个名为nginx的镜像。我们之所以看到这个消息是因为当我们执行 docker run 的时候我们告诉Docker启动一个容器,一个基于名为nginx镜像的容器。因为Docker正在启动一个基于特定镜像的容器,它需要首先找到这个镜像。在检查远端的仓库之前,Docker首先检查是否本地已经存在有一个该特定名称的镜像。

因为我们的系统是全新的,没有一个名为nginx的Docker镜像,这意味着Docker需要在Docker仓库里面下载:

Pulling repository nginx    5c82215b03d1: Download complete     e2a4fb18da48: Download complete     58016a5acc80: Download complete     657abfa43d82: Download complete     dcb2fe003d16: Download complete     c79a417d7c6f: Download complete     abb90243122c: Download complete     d6137c9e2964: Download complete     85e566ddc7ef: Download complete     69f100eb42b5: Download complete     cd720b803060: Download complete     7cc81e9a118a: Download complete 

这正如输出的第二部分所显示的一样。默认情况下,Docker使用Docker Hub仓库,这是由Docker公司运行的仓库服务。

如Github一样,Docker Hub对于公共的仓库免费,但是对于私有仓库需要付费。然而,你也可以部署你自己的Docker仓库,实际上这只是运行 docker run registry 这么简单。在这篇文章中,我们不会部署一个自己的注册表服务(registry service)。

停止和移除镜像

在我们开始构建一个自己的Docker容器之前,让我们首先清理我们的Docker环境。我们需要停止之前启动的容器并且移除它。

要启动一个Docker容器我们执行 docker 命令并且使用 run 命令选项,要停止这个已启动的镜像我们只需要执行 docker 命令并使用 kill 选项并指定该容器的名称。

# docker kill desperate_lalande    desperate_lalande

如果我们再次执行 docker ps 我们看到容器没有运行了。

docker ps

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

然而在这一刻,我们仅仅停止了这个容器,尽管它没有运行了,但是它还是存在的。默认情况下 docker ps 值会显示运行中的容器,如果我们添加了 -a (all,所有)标志,它就会显示所有不管是否运行与否的容器。

docker ps -a

CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS                           PORTS               NAMES

f6d31ab01fc9        5c82215b03d1        nginx -g 'daemon off   4 weeks ago         Exited (-1) About a minute ago                       desperate_lalande  

要完全的移除该容器,我们可以使用 docker 命令并且使用 rm 命令选项。

docker rm desperate_lalande

desperate_lalande

尽管这个容器已经被移除了;我们仍然可以随时使用nginx镜像。我们想要重新运行 docker run -d nginx ,容器将会立即启动而不需要再次拉取名叫nginx的镜像。这是因为Docker已经在本地保存了一个备份。

要查看所有本地的镜像我们可以是运行docker命令并且使用 image 选项。

docker images

REPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZE

nginx               latest              9fab4090484a        5 days ago          132.8 MB

构建我们自己的自定义镜像

到目前为止我们已经使用了一些基础的Docker命令来启动,停止和移除一个普通的预选构建好的镜像。而为了Docker化这个博客,我们将要构建我们自己的Docker镜像,这意味要创建一个Docker文件。

在绝大多数的虚拟机环境中,假如你想要创建一个机器的镜像,你需要首先创建一个虚拟机,安装好操作系统,安装好应用程序,最后将其转化成一个模板或者镜像。然而,对于Docker来说,这些步骤都可以通过Dockerfile进行自动化。一个Dockerfile是一个可以向Docker提供构建指令的方式。在这一节中,我们将要创建一个可以用来部署本博客的自定义Dockerfile。

理解应用

在我们开始创建Dockerfile之前,我们需要首先理解要不是这个博客我们需要什么。

这个博客自身实际上是通过一个自己便写的名为hamerkop的静态网站生成器生成的一个静态的HTML网页。这个生成器非常的简单,专门为这个博客编写,凑合能用就行。这个博客的所有的代码和源文件都能在公共的 Github仓库 中访问到。为了部署这个博客我们只需要从Github拿到该仓库的内容,然后安装Python和一些Python的模块,并且执行 hamerkop 应用。要对外服务这些生成的内容我们需要使用nginx;这意味着我们也需要安装好nginx。

到目前为止,我们的Dockerfile应该足够简单,但是就这些也足够让我们学到不少 Dockerfile语法 。要开始我们克隆Github仓库代码然后用最喜欢的编辑器来创建一个Dockerfile;我这里使用vi。

git clone https://github.com/madflojo/blog.git

Cloning into 'blog'...

remote: Counting objects: 622, done.

remote: Total 622 (delta 0), reused 0 (delta 0), pack-reused 622

Receiving objects: 100% (622/622), 14.80 MiB | 1.06 MiB/s, done.

Resolving deltas: 100% (242/242), done.

Checking connectivity... done.

cd blog/

vi Dockerfile

FROM - 继承一个Docker镜像

Dockerfile的第一条指令是FROM指令。这是用来将一个已经存在的Docker镜像指定为基础镜像。这基本上为我们提供了继承另一个Docker镜像的方法。在这个情形下,我们将会从我们之前用到的nginx镜像开始,如果我们想要从一个最原始的开始,我们可以通过指定 ubuntu:latest 使用 Ubuntu 镜像。

## 本Dockerfile用来生成http://bencane.com一个实例    FROM nginx:latest    MAINTAINER Benjamin Cane <ben@bencane.com>

除了FROM指令之外,我还包含了一个MAINTAINER指令,其是用来显示Dockerfile的作者的。

因为Docker支持使用#来作为评论的标示,我将会使用这种语法来解释Dockerfile的各个部分。

运行一个测试构建

因为我们继承了nginx Docker镜像,我们当前的Dockerfil也继承了所有用来构建该nginx镜像的所有指令。这意味着即使在这一刻,我们已经能从这个Dockerfile中构建出一个Docker镜像并且用这个镜像运行出一个容器。生成的镜像基本上跟nginx镜像一样,但是我们现在就开始构建这个Dockerfile,之后还有几次,以此来帮助解释Docker的构建过程。

为了开始从一个Dockerfile中开始一个构建,我们可以简单地执行 docker 命令并且使用 build 命令选项。

# docker build -t blog /root/blog     Sending build context to Docker daemon  23.6 MB    Sending build context to Docker daemon     Step 0 : FROM nginx:latest    ---> 9fab4090484a    Step 1 : MAINTAINER Benjamin Cane <ben@bencane.com>    ---> Running in c97f36450343    ---> 60a44f78d194    Removing intermediate container c97f36450343    Successfully built 60a44f78d194

在上面的例子中,我们使用 -t (tag,标签)标志来将这个镜像贴上名为"blog"的标签。这基本上让我们可以对镜像进行命令,假如不为镜像指定一个标签,这个镜像就只能通过一个由Dokcer指定的镜像ID(Image ID)来调用。在这个场景下,这镜像ID是 60a44f78d194 ,正如我们在docker命令的成功构建消息中看到的一样。

除了 -t 标志,我也指定了 /root/blog 目录。这个目录就是“构建目录(build directory)”,这个目录包含了Dockerfile和其他必要的构建这个容器的文件。

现在我们已经完成了一个成功的构建,让我们开始对这个镜像进行定制化。

使用 RUN 来执行apt-get

这个用来生成HTML页面的静态网站生成器是使用Python来编写的,因此在Dockerfile中第一个自定义的任务是安装Python。要安装Python包,我们需要用到Apt包管理器,这意味着我们需要在Dockerfile中说明需要执行 apt-get update 和 apt-get install python-dev ;我们可以通过RUN指令来完成这一点。

## Dockerfile that generates an instance of http://bencane.com    FROM nginx:latest    MAINTAINER Benjamin Cane <ben@bencane.com>

Install python and pip

RUN apt-get update

RUN apt-get install -y python-dev python-pip

在上面我们仅仅是运行RUN指令来告诉Docker当其构建这个镜像的时候,它需要执行指定的apt-get命令。然而有意思的部分是,这些命令只会在这个容器的情景中才会值性。这意味着即使 python-dev 和 python-pip 安装到了容器中,却没有安装到主机中。或者更简单的话说,在在容器中,pip是可以执行的,但是出了容器之外,pip命令是找不到的。

同样重要的一点是,Docker的构建过程中是不接受用户的输入的。这意味这所有由RUN指令来执行的命令必须不藉由用户输入而完成。这给构建过程增加了一点复杂度,因为很多应用是需要用户输入的。在我们这个场景中,RUN所有执行的命令都不需要用户输入。

安装Python模块

现在Python安装好了我们需要安装一些Python模块。要在Docker之外做这件事情,我们通常是使用pip命令并且引用在博客的仓库中的一个名叫 requirements.txt 文件。在之前的一个步骤里,我们使用了git命令将博客的Github仓库克隆到/root/blog目录下;这个目录也同时是我们创建Dockerfile的地方。这很重要,因为这意味着Git仓库的内容能被Docker在构建过程中访问到。

当执行构建的时候,Docker会将构建的情景设置成为一个指定的构建目录。这意味该文件夹任何的文件以及子目录中的文件都能被构建过程所使用,而处于该目录之外(处于构建情景之外的),是不能被访问到的。

要安装必需的Python模块,我们需要将requirements.txt文件从构建目录拷贝到容器之中去。我们可以在Dockerfile中使用 COPY 指令。

## Dockerfile that generates an instance of http://bencane.com     FROM nginx:latest MAINTAINER Benjamin Cane <ben@bencane.com>

Install python and pip

RUN apt-get update 

RUN apt-get install -y python-dev python-pip

Create a directory for required files

RUN mkdir -p /build/

Add requirements file and run pip

COPY requirements.txt /build/ 

RUN pip install -r /build/requirements.txt

在Dockerfile中我们添加了3个指定。第一个指令使用 RUNS 在容器中创建了一个 /build 目录。这个目录将用来拷贝任何所用来生成静态HTML页面所需的任何文件。第二个指令是COPY指令,用来将requirements.txt从构建目录拷贝到容器中的/build目录。第三个使用了RUN指令,用来执行pip命令;这会安装所有在requirements.txt文件中指定的模块。

COPY是一个当构建定制化的镜像时需要理解的很重要的指令。没有Dockerfile文件中指定复制文件,Docke镜像就不会包含这个requirements.txt文件。在Docker容器一切都是被隔离的情况下,除非在Dockerfile中特别指定过,容器是不大可能包含所需的依赖的。

重新运行一个构建

现在我们有了一些可以让Docker执行的定制化的任务了,让我们来试着对这个blog镜像进行另外一次构建。

docker build -t blog /root/blog

Sending build context to Docker daemon 19.52 MB

Sending build context to Docker daemon 

Step 0 : FROM nginx:latest

---> 9fab4090484a

Step 1 : MAINTAINER Benjamin Cane <ben@bencane.com>

---> Using cache

---> 8e0f1899d1eb

Step 2 : RUN apt-get update

---> Using cache

---> 78b36ef1a1a2

Step 3 : RUN apt-get install -y python-dev python-pip

---> Using cache

---> ef4f9382658a

Step 4 : RUN mkdir -p /build/

---> Running in bde05cf1e8fe

---> f4b66e09fa61

Removing intermediate container bde05cf1e8fe

Step 5 : COPY requirements.txt /build/

---> cef11c3fb97c

Removing intermediate container 9aa8ff43f4b0

Step 6 : RUN pip install -r /build/requirements.txt

---> Running in c50b15ddd8b1

Downloading/unpacking jinja2 (from -r /build/requirements.txt (line 1))

Downloading/unpacking PyYaml (from -r /build/requirements.txt (line 2))

<truncated to reduce noise>

Successfully installed jinja2 PyYaml mistune markdown MarkupSafe

Cleaning up...

---> abab55c20962

Removing intermediate container c50b15ddd8b1

Successfully built abab55c20962

从上面的构建输出我们可以看到构建成功了,但是我们也可以看到另外一个有意思的消息; ---> Using cache (使用缓存)。这个消息告诉我们的是,Docker能够在构建过程中使用他的构建缓存。

Docker构建缓存

当Dcoker构建一个镜像的时候,它不会仅仅构建一个单一的镜像;它实际上在整个构建过程中会构建出多个镜像。实际上我们可以从以上的输出看到,在每一步之后,Docker都创建了一个新的镜像。

Step 5 : COPY requirements.txt /build/    ---> cef11c3fb97c

上面片段中的最后一行实际上是Docker在告诉我们创建了一个新的镜像,它通过输出镜像ID来告诉我们这一点;cef11c3fb97c。这个策略一个有用之处在于Docker能够使用这些镜像作为后续构建步骤的缓存。这很有用,因为它能让Docker加快同样容器的新构建的构建过程。如果仔细我们看上面的例子,我们可以发现Docker能够使用一个已经缓存了的镜像,而不是重新安装python-dev和python-pip包。然而,因为Docker无法一个构建执行过mkdir命令,每一个后续的步骤都执行了。

Docker的构建缓存即是一个馈赠也是一个诅咒;这么说的原因是要使用缓存或者重新执行指令这个决定是在一个很狭窄的范围做出的。比如,如果如果有对 requirements.txt 文件的更改,Docker会在构建过程中检测到这个改动然后从那一点重新开始。然而执行 apt-get 命令却情况不同。如果提供Python包的Apt仓库包含了一个 python-pip 包更新的版本;Docker无法检测到这个变化,然后简单地使用缓存。这意味着可能我们安装了软件包的一个较老的版本。尽管这个对于 python-pip 软件包来说这不是什么问题,如果安装包缓存了一个包含已知漏洞的软件包,那么就是一个问题。

介于这个原因,周期性的重新构建镜像并且不使用Docker的缓存是有用的。你可以在执行一个Dcoker构建的时候制定 --no-cache=True 来禁用缓存。

部署blog的其余部分

当Python软件包和模块都安装好后,现在我们应该拷贝必需的应用文件,然后运行 hamerkop 应用了。要完成这一步我们可以简单地使用更多的 COPY 和 RUN 指令。

FROM nginx:latest    MAINTAINER Benjamin Cane <ben@bencane.com>

安装python和pip

RUN apt-get update

RUN apt-get install -y python-dev python-pip

创建一个文件夹放置必需文件

RUN mkdir -p /build/

添加依赖文件然后运行pip

COPY requirements.txt /build/

RUN pip install -r /build/requirements.txt

添加博客代码和必需文件

COPY static /build/static

COPY templates /build/templates

COPY hamerkop /build/

COPY config.yml /build/

COPY articles /build/articles

运行生成器

RUN /build/hamerkop -c /build/config.yml

现在我们补上了其余的构建指令了,让我们来再来一次构建并且验证是否镜像能够构建成功。

docker build -t blog /root/blog/

Sending build context to Docker daemon 19.52 MB

Sending build context to Docker daemon 

Step 0 : FROM nginx:latest

---> 9fab4090484a

Step 1 : MAINTAINER Benjamin Cane <ben@bencane.com>

---> Using cache

---> 8e0f1899d1eb

Step 2 : RUN apt-get update

---> Using cache

---> 78b36ef1a1a2

Step 3 : RUN apt-get install -y python-dev python-pip

---> Using cache

---> ef4f9382658a

Step 4 : RUN mkdir -p /build/

---> Using cache

---> f4b66e09fa61

Step 5 : COPY requirements.txt /build/

---> Using cache

---> cef11c3fb97c

Step 6 : RUN pip install -r /build/requirements.txt

---> Using cache

---> abab55c20962

Step 7 : COPY static /build/static

---> 15cb91531038

Removing intermediate container d478b42b7906

Step 8 : COPY templates /build/templates

---> ecded5d1a52e

Removing intermediate container ac2390607e9f

Step 9 : COPY hamerkop /build/

---> 59efd1ca1771

Removing intermediate container b5fbf7e817b7

Step 10 : COPY config.yml /build/

---> bfa3db6c05b7

Removing intermediate container 1aebef300933

Step 11 : COPY articles /build/articles

---> 6b61cc9dde27

Removing intermediate container be78d0eb1213

Step 12 : RUN /build/hamerkop -c /build/config.yml

---> Running in fbc0b5e574c5

Successfully created file /usr/share/nginx/html//2011/06/25/checking-the-number-of-lwp-threads-in-linux

Successfully created file /usr/share/nginx/html//2011/06/checking-the-number-of-lwp-threads-in-linux

<truncated to reduce noise>

Successfully created file /usr/share/nginx/html//archive.html

Successfully created file /usr/share/nginx/html//sitemap.xml

---> 3b25263113e1

Removing intermediate container fbc0b5e574c5

Successfully built 3b25263113e1

运行一个定制化的容器

在成功构建后,我们现在可以通过运行docker命令并且使用 run 选项来启我们的定制化的容器,就如我们之前运行nginx容易类似。

docker run -d -p 80:80 --name=blog blog

5f6c7a2217dcdc0da8af05225c4d1294e3e6bb28a41ea898a1c63fb821989ba1

与先前一样, -d (脱离)标志是用来告诉Docker在后台运行容器。然后我们这里也使用两个新的标志。第一个标志是 --name ,这是用来给容器一个用户指定名称。在之前的例子里,我们没有指定名称,因此Docker随机生成了一个名称。第二个新出现的标志是 -p ,这个标志能让用户来将一个端口从主机机器映射到容器中的一个端口。

我们使用的nginx基础镜像暴露了80端口来提供HTTP服务。默认情况下,与Docker容器内部绑定的端口并没有与主机系统绑定。为了让外部的系统访问容器内部暴露的端口,这些端口必须通过使用 -p 标志从主机端口映射到容器端口。假如我们想要端口从主机的8080端口,映射到容器中的80端口,我们可以通过使用这种语法 -p 8080:80 。

从上面的命令中,看起来我们的容器已经启动成功了。我们可以通过运行执行 docker ps来验证 。

{{{

docker ps

CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

d264c7ef92bd blog:latest nginx -g 'daemon off 3 seconds ago Up 3 seconds 443/tcp, 0.0.0.0:80->80/tcp blog

{{{

总结

到这里,我们已经有了一个运行中的自定制的Docker容器。尽管这个文章中我们触及到了不少的Dockerfile指令,我们还未能讨论所有的指令。要获取一个完整的指令列表你可以查看 Docker的手册页面 ,那里很好的解释了每个指令。

另外一个很好的资源是 Docker最佳实践 页面,包含了不少的构建自定制Dockerfile最佳实践。有些点子是分有用,比如策略性地在Dockerfile中安排指令的顺序。在上面的例子中,我们的Dockerfile用到的COPY指令,被放在了最后。这么做的原因是,articles目录会频繁更改。最好将可能经常变动的指令放到最后,这样可以优化Docker的缓存步骤。

在这个文章中我覆盖了如何从一个预先构建好的容器开始,如何构建然后部署一个自定制的容器。尽管Docker要学习的内容还有很多,这篇文章会助你迈出第一步。

来自: http://dockone.io/article/961