Skip to content

Docker

Docker 是一种轻量级虚拟化技术。 最初由 dotCloud 公司在 2013 年发布。自发布以来,其发展速度之快超乎了很多人的想象,一路高歌猛进,2014 年 6 月终于发布了 1.0 稳定版,而 dotCloud 在 2013 年 10 月干脆连公司名字也改为了 Docker, Inc.。

与传统的 VM 相比,它更轻量,启动速度更快,单台硬件上可以同时跑成百上千个容器,所以非常适合在业务高峰期通过启动大量容器进行横向扩展。

Docker 是可移植(或者说跨平台)的,可以在各种主流 Linux 发布版或者 OS X 以及 Windows 上(需要使用 boot2docker 或者虚拟机)使用。Java 可以做到“一次编译,到处运行”,而 Docker 则可以称为“构建一次,在各平台上运行”(Build once,run anywhere)。

简介

Docker 简介

Docker 是一个能够把开发的应用程序自动部署到容器的开源引擎。

那么 Docker 有什么特别之处呢?Docker 在虚拟化的容器执行环境中增加了一个应用程序部署引擎。该引擎的目标就是提供一个轻量、快速的环境,能够运行开发者的程序,并方便高效地将程序从开发者的笔记本部署到测试环境,然后再部署到生产环境。Docker 极其简洁,它所需的全部环境只是一台仅仅安装了兼容版本的 Linux 内核和二进制文件最小限的宿主机。

Docker 组件

Docker 的核心组件:

  • Docker 客户端和服务器(Docker 引擎)
  • Docker 镜像
  • Registry 仓库
  • Docker 容器

Docker 客户端和服务器

Docker 是一个客户端/服务器(C/S)架构的程序。Docker 客户端只需向 Docker 服务器或守护进程发出请求,服务器或守护进程将完成所有工作并返回结果。Docker 守护进程有时也称为 Docker 引擎。Docker 提供了一个命令行工具 docker 以及一整套 RESTful API 来与守护进程交互。用户可以在同一台宿主机上运行 Docker 守护进程和客户端,也可以从本地的 Docker 客户端连接到运行在另一台宿主机上的远程 Docker 守护进程。

(Docker 的架构。图片来源:THE DOCKER BOOK)

Docker 镜像

镜像是构建 Docker 世界的基石。用户基于镜像来运行自己的容器。镜像也是 Docker 生命周期中的“构建”部分。镜像是基于联合(Union)文件系统的一种层式的结构,由一系列指令一步一步构建出来。例如:

  • 添加一个文件;
  • 执行一个命令;
  • 打开一个端口。

也可以把镜像当作容器的“源代码”。镜像体积很小,非常“便携”,易于分享、存储和更新。

Registry 仓库

Docker 用 Registry 来保存用户构建的镜像。Registry 分为公共和私有两种。

Docker 公司运营的公共 Registry 叫作 Docker Hub。用户可以在 Docker Hub 注册账号,分享并保存自己的镜像。

用户也可以在 Docker Hub 上保存自己的私有镜像。例如,包含源代码或专利信息等需要保密的镜像,或者只在团队或组织内部可见的镜像。

用户甚至可以架设自己的私有 Registry。私有 Registry 可以受到防火墙的保护,将镜像保存在防火墙后面,以满足一些组织的特殊需求。

Docker 容器

Docker 可以帮用户构建和部署容器,用户只需要把自己的应用程序或服务打包放进容器即可。容器是基于镜像启动起来的(就像 Java 中的类和对象),容器中可以运行一个或多个进程。我们可以认为,镜像是 Docker 生命周期中的构建或打包阶段,而容器则是启动或执行阶段

总结起来,Docker 容器就是:

  • 一个镜像格式;
  • 一系列标准的操作;
  • 一个执行环境。

Docker 借鉴了标准集装箱的概念。唯一不同的是:集装箱运输货物,而 Docker 运输软件。

每个容器都包含一个软件镜像,也就是容器的“货物”,而且与真正的货物一样,容器里的软件镜像可以进行一些操作。例如,镜像可以被创建、启动、关闭、重启以及销毁。

和集装箱一样,Docker 在执行上述操作时,并不关心容器中到底塞进了什么,它不管里面是 Web 服务器,还是数据库,或者是应用程序服务器什么的。所有容器都按照相同的方式将内容“装载”进去。

Docker 也不关心用户要把容器运到何方:用户可以在自己的笔记本中构建容器,上传到 Registry,然后下载到一个物理的或者虚拟的服务器来测试,再把容器部署到云主机的集群中去。像标准集装箱一样,Docker 容器方便替换,可以叠加,易于分发,并且尽量通用。

能用 Docker 做什么

Docker的一些应用场景如下。

  • 加速本地开发和构建流程,使其更加高效、更加轻量化。本地开发人员可以构建、运行并分享 Docker 容器。容器可以在开发环境中构建,然后轻松地提交到测试环境中,并最终进入生产环境。

  • 能够让独立服务或应用程序在不同的环境中,得到相同的运行结果。这一点在面向服务的架构和重度依赖微型服务的部署中尤其实用。

  • 用 Docker 创建隔离的环境来进行测试。例如,用 Jenkins CI 这样的持续集成工具启动一个用于测试的容器。

  • Docker 可以让开发者先在本机上构建一个复杂的程序或架构来进行测试,而不是一开始就在生产环境部署、测试。

  • 构建一个多用户的平台即服务(PaaS)基础设施。

  • 为开发、测试提供一个轻量级的独立沙盒环境,或者将独立的沙盒环境用于技术教学,如 Unix shell 的使用、编程语言教学。

  • 提供软件即服务(SaaS)应用程序。

  • 高性能、超大规模的宿主机部署。

Docker 与配置管理

Docker 一个显著的特点就是,对不同的宿主机、应用程序和服务,可能会表现出不同的特性与架构(或者确切地说,Docker 本就是被设计成这样的):Docker 可以是短生命周期的,但也可以用于恒定的环境,可以用一次即销毁,也可以提供持久的服务。这些行为并不会给 Docker 增加复杂性,也不会和配置管理工具的需求产生重合。基于这些行为,我们基本不需要担心管理状态的持久性,也不必太担心状态的复杂性,因为容器的生命周期往往比较短,而且重建容器状态的代价通常也比传统的状态修复要低。

由于多样化的管理需求,以及管理 Docker 自身的需求,在绝大多数组织中,Docker 和配置管理工具可能都需要部署。

Docker 的技术组件

Docker 可以运行于任何安装了现代 Linux 内核的 x64 主机上。推荐的内核版本是 3.8 或者更高。Docker 的开销比较低,可以用于服务器、台式机或笔记本。它包括以下几个部分。

  • 一个原生的 Linux 容器格式,Docker中称为 libcontainer。

  • Linxu 内核的命名空间(namespace),用于隔离文件系统、进程和网络。

  • 文件系统隔离:每个容器都有自己的 root 文件系统。

  • 进程隔离:每个容器都运行在自己的进程环境中。

  • 网络隔离:容器间的虚拟网络接口和 IP 地址都是分开的。

  • 资源隔离和分组:使用 cgroups(即 control group,Linux 的内核特性之一)将 CPU 和内存之类的资源独立分配给每个 Docker 容器。

  • 写时复制:文件系统都是通过写时复制创建的,这就意味着文件系统是分层的、快速的,而且占用的磁盘空间更小。

  • 日志:容器产生的 STDOUT 、STDERR 和 STDIN 这些 IO 流都会被收集并记入日志,用来进行日志分析和故障排错。

  • 交互式 Shell:用户可以创建一个伪 TTY 终端,将其连接到 STDIN ,为容器提供一个交互式的 Shell。

安装 Docker

Docker 的安装既快又简单。目前,Docker 已经支持非常多的 Linux 平台,包括 Ubuntu 和 RHEL(Red Hat Enterprise Linux,Red Hat 企业版 Linux)。除此之外,Docker 还支持 Debian、CentOS、Fedora、Oracle Linux 等衍生系统和相关的发行版。如果使用虚拟环境,甚至也可以在 OS X 和 Microsoft Windows 中运行 Docker。

安装 Docker 的先决条件

和安装其他软件一样,安装 Docker 也需要一些基本的前提条件。Docker 要求的条件具体如下。

  • 运行 64 位 CPU 构架的计算机(目前只能是 x86_64 和 amd64),请注意,Docker 目前不支持32位CPU。

  • 运行 Linux 3.8 或更高版本内核。一些老版本的 2.6.x 或其后的内核也能够运行 Docker,但运行结果会有很大的不同。而且,如果需要就老版本内核寻求帮助,通常大家会被建议升级到更高版本的内核。

  • 内核必须支持一种适合的存储驱动(Storage Driver),例如:

    • Device Manager;
    • AUFS;
    • vfs;
    • btrfs;
    • ZFS(在Docker 1.7 中引入);
    • 默认存储驱动通常是 Device Mapper 或 AUFS(在 Docker 1.13.1 中是 overlay2 或 overlay(如果内核支持))。
  • 内核必须支持并开启 cgroup 和命名空间(namespace)功能。

在 Red Hat 和 Red Hat 系发行版中安装 Docker

在 Red Hat 企业版 Linux(或者 CentOS 或 Fedora)中,只有少数几个版本可以安装 Docker,包括:

  • RHEL(和 CentOS)6或以上的版本(64 位)
  • Fedora 19 或以上的版本(64 位)

检查前提条件

在 Red Hat 和 Red Hat 系列的 Linux 发行版中,安装 Docker 所需的前提条件也并不多。

  1. 内核

    使用 uname 命令来确认是否安装了 3.8 或更高的内核版本:

    sh
    $ uname -a
    Linux localhost.localdomain 3.10.0-1127.el7.x86_64 #1 SMP Tue Mar 31 23:36:51 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
  2. 检查 Device Mapper(v1.13.1 以下)

    在 Red Hat 企业版 Linux、CentOS 6 或 Fedora 19 及更高版本宿主机中,应该也都安装了 Device Mapper:

    sh
    $ ls -l /sys/class/misc/device-mapper
    lrwxrwxrwx. 1 root root 0 8月  10 17:15 /sys/class/misc/device-mapper -> ../../devices/virtual/misc/device-mapper

    同样,也可以在 /proc/devices 文件中检查是否有 device-mapper 条目:

    sh
    $ grep device-mapper /proc/devices
    253 device-mapper

    如果没有检测到 Device Mapper,也可以试着安装 device-mapper 软件包:

    sh
    $ yum install -y device-mapper

    安装完成后,还需要加载 dm_mod 内核模块:

    sh
    modprobe dm_mod

提示

在 Docker v1.13.1 中,默认的 Storage Driver 已经由 Device Mapper 变成了 overlay2 或 overlay(如果内核支持)

https://github.com/moby/moby/releases/tag/v1.13.1

安装 Docker

  • 在 RHEL 7 中安装 Docker

    sh
    $ yum install -y docker

    安装完毕后,查看 Docker 版本:

    sh
    $ docker -v
    Docker version 1.13.1, build 64e9980/1.13.1
在 CentOS 上安装最新版本的 Docker

需要 CentOS 7 及以上版本才能安装最新版本的 Docker,具体请参考官网

  1. 卸载旧版本的 Docker。

    sh
    $ yum remove -y docker \
    				docker-client \
    				docker-client-latest \
    				docker-common \
    				docker-latest \
    				docker-latest-logrotate \
    				docker-logrotate \
    				docker-engine
  2. 设置 Docker 存储库。之后,可以从存储库安装和更新 Docker。

    sh
    $ yum install -y yum-utils
    $ yum-config-manager \
    			--add-repo \
    			https://download.docker.com/linux/centos/docker-ce.repo
  3. 安装最新版本的 Docker 引擎和容器。

    sh
    $ yum install -y docker-ce docker-ce-cli containerd.io

启动 Docker 守护进程

  • 在 RHEL 7 中启动 Docker 服务

    sh
    $ systemctl start docker

    系统开机自动启动 Docker 服务:

    sh
    $ systemctl enable docker
    Created symlink from /etc/systemd/system/multi-user.target.wants/docker.service to /usr/lib/systemd/system/docker.service.

    docker info 命令来确认 Docker 是否已经正确安装并运行:

    sh
    $ docker info
    Client:
     Debug Mode: false
    
    Server:
     Containers: 0
      Running: 0
      Paused: 0
      Stopped: 0
     Images: 0
     Server Version: 19.03.13
     Storage Driver: overlay2
      Backing Filesystem: extfs
      Supports d_type: true
      Native Overlay Diff: true
     Logging Driver: json-file
     Cgroup Driver: cgroupfs
    ...(省略)...

Docker 入门

运行第一个容器

现在,尝试启动第一个 Docker 容器。

使用 docker run 命令创建容器。docker run 命令提供了 Docker 容器的创建到启动的功能。

sh
$ docker run -i -t ubuntu /bin/bash
Unable to find image 'ubuntu:latest' locally
Trying to pull repository docker.io/library/ubuntu ...
latest: Pulling from docker.io/library/ubuntu
3ff22d22a855: Pull complete
e7cb79d19722: Pull complete
323d0d660b6a: Pull complete
b7f616834fd0: Pull complete
Digest: sha256:5d1d5407f353843ecf8b16524bc5565aa332e9e6a1297c73a92d3e754b8a636d
Status: Downloaded newer image for docker.io/ubuntu:latest
root@e1b1f6c2b1fe:/#
  • -i:保证容器中的 STDIN 是开启的
  • -t:为要创建的容器分配一个伪 TTY 终端

若要在命令行下创建一个能与之进行交互的容器,而不是一个运行后台服务的容器,则这两个参数已经是最基本的参数了。

接下来,告诉 Docker 基于 Ubuntu 镜像来创建容器。

提示

Ubuntu 镜像是一个常备镜像,也可以称为“基础”(Base)镜像,它由 Docker 公司提供,保存在 Docker Hub Registry 上。可以以 Ubuntu 基础镜像(以及类似的 Fedora、Debian、CentOS 等镜像)为基础,在选择的操作系统上构建自己的镜像。

那么,在这一切的背后又都发生了什么呢?

首先,Docker 会检查本地是否存在 Ubuntu 镜像,如果本地还没有该镜像的话,那么 Docker 就会连接官方维护的 Docker Hub Registry,查看 Docker Hub 中是否有该镜像。Docker 一旦找到该镜像,就会下载该镜像并将其保存到本地宿主机中。

随后,Docker 在文件系统内部用这个镜像创建了一个新容器。该容器拥有自己的网络、IP 地址,以及一个用来和宿主机进行通信的桥接网络接口。

最后,我们告诉 Docker 在新容器中要运行 /bin/bash 命令启动一个 Bash Shell。

使用第一个容器

现在,我们已经以 root 用户登录到了新容器中,容器的ID e1b1f6c2b1fe。这是一个完整的 Ubuntu 系统,可以用它来做任何事情。下面就来研究一下这个容器。

首先,我们可以获取该容器的主机名:

sh
root@e1b1f6c2b1fe:/# hostname
e1b1f6c2b1fe

可以看到,容器的主机名就是该容器的 ID。

再来看看 /etc/hosts 文件:

sh
root@e1b1f6c2b1fe:/# cat /etc/hosts
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.2      e1b1f6c2b1fe

Docker 已在 hosts 文件中为该容器的 IP 地址添加了一条主机配置项。

再来看看容器的网络配置情况:

sh
root@e1b1f6c2b1fe:/# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
18: eth0@if19: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.0.2/16 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:acff:fe11:2/64 scope link
       valid_lft forever preferred_lft forever

可以看到,这里有 lo 的环回接口,还有 IP 为 172.17.0.2 的标准 eth0 网络接口,和普通宿主机是完全一样的。

还可以查看容器中运行的进程:

sh
root@e1b1f6c2b1fe:/# ps -aux
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.2  0.1   4100  1988 ?        Ss   02:24   0:00 /bin/bash
root         8  0.0  0.0   5872  1408 ?        R+   02:24   0:00 ps -aux

安装一个软件包怎么样?比如 Vim:

sh
root@e1b1f6c2b1fe:/# apt update -y && apt install -y vim

当所有工作都结束时,输入 exit 命令,就可以返回到 Ubuntu 宿主机的命令行提示符了。

sh
root@e1b1f6c2b1fe:/# exit
exit
$

这个容器现在怎样了?

容器现在已经停止运行了!只有在指定的 /bin/bash 命令处于运行状态的时候,我们的容器也才会相应地处于运行状态。一旦退出容器,/bin/bash 命令也就结束了,这时容器也随之停止了运行。

容器虽然停止运行,但容器仍然是存在的。

可以用 docker ps -a 命令查看当前系统中容器的列表:

sh
$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
e1b1f6c2b1fe        ubuntu              "/bin/bash"         22 minutes ago      Exited (0) 1 minutes ago                       frosty_bohr
# 容器 ID 			创建该容器的镜像		容器最后执行的命令	容器创建时间			容器状态(0-正常退出)							容器名称

默认情况下,当执行 docker ps 命令时,只能看到正在运行的容器。

  • -a:列出所有容器,包括正在运行的和已经停止的

容器的命名

Docker 会为我们创建的每一个容器自动生成一个随机的名称。例如,上面刚刚创建的容器就被命名为 frosty_bohr。

如果想为容器指定一个名称,而不是使用自动生成的名称,则可以用 --name 选项来实现:

sh
$ docker run --name my_ubuntu -i -t ubuntu /bin/bash
root@e8a11190cf34:/# exit
exit
$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                      PORTS               NAMES
e8a11190cf34        ubuntu              "/bin/bash"         8 seconds ago       Exited (0) 5 seconds ago                        my_ubuntu
e1b1f6c2b1fe        ubuntu              "/bin/bash"         About an hour ago   Exited (0) 53 minutes ago                       frosty_bohr

可以看到,上述命令又新建了一个容器,并且容器的名称是 my_ubuntu。

提示

一个合法的容器名称只能包含以下字符:小写字母 a~z、大写字母 A~Z、数字 0~9、下划线 _、圆点 .、横线 -(如果用正则表达式来表示这些符号,就是[a-zA-Z0-9_.-])。

在很多 Docker 命令中,都可以用容器的名称来替代容器 ID。

好处:

  • 有助于分辨容器
  • 当构建容器和应用程序之间的逻辑连接时,容器的名称也有助于从逻辑上理解连接关系。

具体的名称(如 web 、db)比容器 ID 和随机容器名好记多了。

但是,容器的命名必须是唯一的。如果试图创建两个名称相同的容器,则命令将会失败。如果要使用的容器名称已经存在,可以先用 docker rm 命令删除已有的同名容器后,再来创建新的容器。

重新启动已经停止的容器

my_ubuntu 容器已经停止了,如何才能重新启动一个已经停止的容器?

sh
$ docker start e8a11190cf34
e8a11190cf34

除了容器 ID,也可以用容器名称来指定容器:

sh
$ docker start my_ubuntu
my_ubuntu

也可以使用 docker restart 命令来重新启动一个容器:

sh
$ docker restart e8a11190cf34
e8a11190cf34

如何创建一个容器却不运行它?

sh
$ docker create --name create_ubuntu -i -t ubuntu /bin/bash
5940d36e87d1685c842980fd3d753102da63b30d02620d0a158aa874be1ac45c
$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                         PORTS               NAMES
5940d36e87d1        ubuntu              "/bin/bash"         17 seconds ago      Created                                            create_ubuntu

可以看到,容器的状态是 Created

附着到容器上

使用 docker startdocker restart 启动容器后,如何才能进入容器的会话中呢?

可以用 docker attach 命令,重新附着到该容器的会话上:

sh
$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
e8a11190cf34        ubuntu              "/bin/bash"         2 hours ago         Up 4 seconds                            my_ubuntu
$ docker attach my_ubuntu
root@e8a11190cf34:/#

同样,也可以使用容器 ID,重新附着到容器的会话上:

sh
root@e8a11190cf34:/# exit
exit
$ docker start my_ubuntu
my_ubuntu
$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
e8a11190cf34        ubuntu              "/bin/bash"         2 hours ago         Up 6 seconds                            my_ubuntu
$ docker attach my_ubuntu
root@e8a11190cf34:/#

警告

需要注意的是,使用 docker attach 命令进入的容器会话,使用 exit 命令退出容器后容器也会随之停止运行

sh
root@e8a11190cf34:/# exit
exit
$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

想要退出但还想保持容器的运行状态?请查看 docker exec

创建守护式容器

除了这些交互式运行的容器(Interactive Container),也可以创建长期运行的容器。守护式容器(Daemonized Container)没有交互式会话,非常适合运行应用程序和服务。大多数时候我们都需要以守护式来运行我们的容器。

启动一个守护式容器:

sh
$ docker run --name daemon_ubuntu -d ubuntu /bin/bash -c "while true; do echo hello world; sleep 1; done"
45bf553af8d52b798246b6463cbfe20d7ac65546af2efe103dc6810ba9e99219
  • -d:Docker 会将容器放到后台运行

而且还在容器要运行的命令里使用了一个 while 循环,该循环会一直打印 “hello world”,直到容器或其进程停止运行。

执行docker ps 命令,可以看到一个正在运行的容器:

sh
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
45bf553af8d5        ubuntu              "/bin/bash -c 'whi..."   33 seconds ago      Up 32 seconds                           daemon_ubuntu

容器内部都在干些什么

现在已经有了一个在后台运行 while 循环的守护型容器。为了探究该容器内部都在干些什么,可以用 docker logs 命令来获取容器的日志。

sh
docker logs daemon_ubuntu
...(省略))...
hello world
hello world
hello world
hello world
hello world
hello world
$

可以看到 while 循环正在向日志里打印 “hello world”。Docker 会输出最后几条日志项并返回。

可以在命令后使用 -f 参数来监控 Docker 的日志,这与 tail -f 命令非常相似:

sh
$ docker logs -f daemon_ubuntu
...(省略))...
hello world
hello world
hello world
hello world
hello world
hello world
...(省略))...

也可以跟踪容器日志的某一片段,和之前类似,只需要在 tail 命令后加入 -f --tail 选项即可。

可以用 docker logs --tail 10 daemon_ubuntu 获取日志的最后 10 行内容:

sh
$ docker logs --tail 10 daemon_ubuntu
hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world
$

也可以用 docker logs --tail 0 -f daemon_ubuntu 命令来跟踪某个容器的最新日志而不必读取整个日志文件:

sh
$ docker logs --tail 0 -f daemon_ubuntu
hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world
hello world
...(省略))...

为了让调试更简单,还可以使用 -t 选项为每条日志项加上时间戳:

sh
$ docker logs -ft daemon_ubuntu
2020-08-08T08:36:03.014655000Z hello world
2020-08-08T08:36:04.017475000Z hello world
2020-08-08T08:36:05.018863000Z hello world
2020-08-08T08:36:06.020292000Z hello world
2020-08-08T08:36:07.021458000Z hello world
2020-08-08T08:36:08.022631000Z hello world
2020-08-08T08:36:09.023815000Z hello world
...(省略))...

查看容器内的进程

使用 docker top 命令查看容器内部运行的进程。

sh
$ docker top daemon_ubuntu
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
root                12001               11985               0                   16:36               ?                   00:00:00            /bin/bash -c while true; do echo hello world; sleep 1; done
root                21190               12001               0                   17:22               ?                   00:00:00            sleep 1

Docker 统计信息

还可以使用 docker stats 命令,它用来显示一个或多个容器的统计信息。

sh
$ docker stats --no-stream daemon_ubuntu
CONTAINER           CPU %               MEM USAGE / LIMIT     MEM %               NET I/O             BLOCK I/O           PIDS
daemon_ubuntu       0.13%               332 KiB / 1.795 GiB   0.02%               656 B / 656 B       0 B / 0 B           2
  • --no-stream:禁用流统计,并且只拉取第一个结果

在容器内部运行进程

可以通过 docker exec 命令在容器内部额外启动新进程。

可以在容器内运行的进程有两种类型:后台任务和交互式任务。后台任务在容器内运行且没有交互需求,而交互式任务则保持在前台运行。

例如,在 daemon_ubuntu 容器内创建一个空文件,文件名为*/etc/new_config_file*:

sh
$ docker exec -d daemon_ubuntu touch /etc/new_config_file
  • -d:Docker 会将容器放到后台运行

也可以在 daemon_ubuntu 容器中启动一个诸如打开 Shell 的交互式任务:

sh
$ docker exec -it daemon_ubuntu /bin/bash
root@45bf553af8d5:/#

这条命令会在 daemon_ubuntu 容器内创建一个新的 Bash 会话,有了这个会话,就可以在该容器中运行其他命令了。

停止守护式容器

要停止守护式容器,只需要执行 docker stop 命令。

sh
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
45bf553af8d5        ubuntu              "/bin/bash -c 'whi..."   47 hours ago        Up 47 hours                             daemon_ubuntu
$ docker stop daemon_ubuntu
daemon_ubuntu
$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

当然,也可以用容器 ID 来指代容器名称。

提示

docker stop 命令会向 Docker 容器进程发送 SIGTERM 信号。如果想快速停止某个容器,也可以使用 docker kill 命令来向容器进程发送 SIGKILL 信号。

自动重启容器

如果由于某种错误而导致容器停止运行,还可以通过 --restart 选项,让 Docker自动重新启动该容器。

--restart 选项会检查容器的退出代码,并据此来决定是否要重启容器。默认的行为是 Docker不会重启容器。

例如,在 docker run 命令中使用 --restart 选项:

sh
$ docker run --restart=always --name restart_daemon_ubuntu -d ubuntu /bin/bash -c "while true; do echo hello world; sleep 1; done"
41a436cac75c3e6b9ad584186b0fd05dd541fc424346db6387c3b3e3c97e0c08
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
836d72968127        ubuntu              "/bin/bash -c 'whi..."   31 seconds ago      Up 30 seconds                                     restart_daemon_ubuntu
  • --restart=always:无论容器的退出代码是什么,Docker都会自动重启该容器
  • --restart=on-failure:只有当容器的退出代码为非 0 值的时候,才会自动重启

另外,on-failure 还接受一个可选的重启次数参数:

--restart=on-failure:5

这样,当容器退出代码为非 0 时,Docker会尝试自动重启该容器,最多重启 5 次。

深入容器

除了通过 docker ps 命令获取容器的信息,还可以使用 docker inspect 来获得更多的容器信息。

sh
$ docker inspect restart_daemon_ubuntu
[
    {
        "Id": "836d7296812719500221fa9658ee6310eaca563134c3cd0c8e44a93c0b0e4823",
        "Created": "2020-08-17T08:14:27.525087834Z",
        "Path": "/bin/bash",
        "Args": [
            "-c",
            "while true; do echo hello world; sleep 1; done"
        ],
        "State": {
            "Status": "running",
            "Running": true,
            "Paused": false,
            "Restarting": false,
            "OOMKilled": false,
            "Dead": false,
            "Pid": 29989,
            "ExitCode": 0,
            "Error": "",
            "StartedAt": "2020-08-17T08:28:38.983253477Z",
            "FinishedAt": "2020-08-17T08:21:43.727147711Z"
        },
...(省略))...

docker inspect 命令会对容器进行详细的检查,然后返回其配置信息,包括名称、命令、网络配置以及很多有用的数据。

也可以用 -f 或者 --format 选项来选定查看结果:

sh
$ docker inspect -f '{{.State.Running}}' restart_daemon_ubuntu
true

删除容器

如果容器已经不再使用,可以使用 docker rm 命令来删除它们。

sh
$ docker rm e1b1f6c2b1fe
e1b1f6c2b1fe

docker rm 命令只能删除未启动的容器,想要删除正在运行的容器,需要使用 docker rm -f

sh
$ docker rm -f restart_daemon_ubuntu
restart_daemon_ubuntu

也可以一次删除多个容器:

sh
$ docker rm daemon_ubuntu create_ubuntu my_ubuntu
daemon_ubuntu
create_ubuntu
my_ubuntu
$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

删除全部容器

sh
$ docker rm `docker ps -a -q`

使用 Docker 镜像和仓库

什么是Docker镜像

Docker 镜像是由文件系统叠加而成。最底端是一个引导文件系统,即 bootfs,这很像典型的 Linux/Unix 的引导文件系统。实际上,当一个容器启动后,它将会被移到内存中,而引导文件系统则会被卸载(Unmount),以留出更多的内存供 initrd 磁盘镜像使用。

到目前为止,Docker 看起来还很像一个典型的 Linux 虚拟化栈。实际上,Docker 镜像的第二层是 root 文件系统 rootfs ,它位于引导文件系统之上。rootfs 可以是一种或多种操作系统(如 Debian 或者 Ubuntu 文件系统)。

在传统的 Linux 引导过程中,root 文件系统会最先以只读的方式加载,当引导结束并完成了完整性检查之后,它才会被切换为读写模式。但是在 Docker 里,root 文件系统永远只能是只读状态,并且 Docker 利用联合加载(Union Mount)技术又会在 root 文件系统层上加载更多的只读文件系统。联合加载指的是一次同时加载多个文件系统,但是在外面看起来只能看到一个文件系统。联合加载会将各层文件系统叠加到一起,这样最终的文件系统会包含所有底层的文件和目录。

Docker 将这样的文件系统称为镜像。一个镜像可以放到另一个镜像的顶部。位于下面的镜像称为父镜像(Parent Image),可以依次类推,直到镜像栈的最底部,最底部的镜像称为基础镜像(Base Image)。最后,当从一个镜像启动容器时,Docker 会在该镜像的最顶层加载一个读写文件系统。我们想在 Docker 中运行的程序就是在这个读写层中执行的。

(Docker 文件系统层。图片来源:THE DOCKER BOOK)

当 Docker 第一次启动一个容器时,初始的读写层是空的。当文件系统发生变化时,这些变化都会应用到这一层上。比如,如果想修改一个文件,这个文件首先会从该读写层下面的只读层复制到该读写层。该文件的只读版本依然存在,但是已经被读写层中的该文件副本所隐藏。

通常这种机制被称为写时复制(Copy On Write),这也是使 Docker 如此强大的技术之一。每个只读镜像层都是只读的,并且以后永远不会变化。当创建一个新容器时,Docker 会构建出一个镜像栈,并在栈的最顶端添加一个读写层。这个读写层再加上其下面的镜像层以及一些配置数据,就构成了一个容器。我们已经知道,容器是可以修改的,它们都有自己的状态,并且是可以启动和停止的。容器的这种特点加上镜像分层框架(Image-Layering Framework),使我们可以快速构建镜像并运行包含我们自己的应用程序和服务的容器。

列出镜像

可以使用 docker images 命令列出 Docker 主机上可用的镜像。

sh
$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
docker.io/ubuntu    latest              1e4467b07108        3 weeks ago         73.9 MB

可以看到,我们已经获得了一个镜像列表,它们都来源于一个名为 ubuntu 的仓库。那么,这些镜像是从何而来的呢?

镜像从仓库下载下来。镜像保存在仓库中,而仓库存在于 Registry 中。默认的 Registry 是由 Docker 公司运营的公共 Registry 服务,即 Docker Hub。

(Docker Hub。图片来源:自己截得)

在 Docker Hub(或者用户自己运营的 Registry)中,镜像是保存在仓库中的。可以将镜像仓库想象为类似 Git 仓库的东西。它包括镜像、层以及关于镜像的元数据(Metadata)。

每个镜像仓库都可以存放很多镜像(比如,ubuntu 仓库包含了 Ubuntu 14.04、16.04、18.04 和 20.04 的镜像)。让我们看一下 ubuntu 仓库的另一个镜像:

sh
$ docker pull ubuntu:18.04
Trying to pull repository docker.io/library/ubuntu ...
18.04: Pulling from docker.io/library/ubuntu
7595c8c21622: Pull complete
d13af8ca898f: Pull complete
70799171ddba: Pull complete
b6c12202c5ef: Pull complete
Digest: sha256:a61728f6128fb4a7a20efaa7597607ed6e69973ee9b9123e3b4fd28b7bba100b
Status: Downloaded newer image for docker.io/ubuntu:18.04

这里使用了 docker pull 命令来拉取 ubuntu 仓库中的 Ubuntu 18.04 镜像。

再来看看 docker images 命令现在会显示什么结果:

sh
$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
docker.io/ubuntu    latest              1e4467b07108        3 weeks ago         73.9 MB
docker.io/ubuntu    18.04               2eb2d388e1a2        3 weeks ago         64.2 MB

可以看到,我们已经得到了 Ubuntu 的 latest 镜像和 18.04 镜像。这表明 ubuntu 镜像实际上是聚集在一个仓库下的一系列镜像。

可以通过在仓库名后面加上一个冒号和标签名来指定该仓库中的某一镜像:

sh
$ docker run -it --name new_container ubuntu:18.04 /bin/bash
root@a95e35acc6d8:/#

拉取镜像

docker run 命令从镜像启动一个容器时,如果该镜像不在本地,Docker 会先从 Docker Hub 下载该镜像。如果没有指定具体的镜像标签,那么 Docker 会自动下载 latest 标签的镜像。

其实也可以通过 docker pull 命令先发制人地将该镜像拉取到本地。使用 docker pull 命令可以节省从一个新镜像启动一个容器所需的时间。

例如,拉取一个 centos:7 基础镜像:

sh
$ docker pull centos:7
Trying to pull repository docker.io/library/centos ...
7: Pulling from docker.io/library/centos
75f829a71a1c: Pull complete
Digest: sha256:19a79828ca2e505eaee0ff38c2f3fd9901f4826737295157cc5212b7a372cd2b
Status: Downloaded newer image for docker.io/centos:7

可以使用 docker images 命令看到这个新镜像已经下载到本地 Docker 宿主机上了。不过这次我们希望能在镜像列表中只看到 centos 镜像的内容。这可以通过在 docker images 命令后面指定镜像名来实现。

sh
$ docker images centos
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
docker.io/centos    7                   7e6257c9f8d8        7 days ago          203 MB

查找镜像

可以通过 docker search 命令来查找所有 Docker Hub 上公共的可用镜像。

sh
docker search nginx
INDEX       NAME                                         DESCRIPTION                                     STARS     OFFICIAL   AUTOMATED
docker.io   docker.io/nginx                              Official build of Nginx.                        13627     [OK]
docker.io   docker.io/jwilder/nginx-proxy                Automated Nginx reverse proxy for docker c...   1863                 [OK]
docker.io   docker.io/richarvey/nginx-php-fpm            Container running Nginx + PHP-FPM capable ...   783                  [OK]
docker.io   docker.io/linuxserver/nginx                  An Nginx container, brought to you by Linu...   126
...(省略))...

上面的命令在 Docker Hub 上查找了所有带有 nginx 的镜像。这条命令会完成镜像查找工作,并返回如下信息:

  • 仓库名(Name);
  • 镜像描述(Description);
  • 用户评价(Stars)—反应出一个镜像的受欢迎程度;
  • 是否官方(Official)—由上游开发者管理的镜像(如 nginx 镜像由 Nginx 团队管理);
  • 自动构建(Automated)—表示这个镜像是由 Docker Hub 的自动构建(Automated Build)流程创建的。

从上面的结果中拉取一个 nginx 镜像:

sh
$ docker pull nginx
Using default tag: latest
Trying to pull repository docker.io/library/nginx ...
latest: Pulling from docker.io/library/nginx
bf5952930446: Pull complete
cb9a6de05e5a: Pull complete
9513ea0afb93: Pull complete
b49ea07d2e93: Pull complete
a5e4a503d449: Pull complete
Digest: sha256:b0ad43f7ee5edbc0effbc14645ae7055e21bc1973aee5150745632a24a752661
Status: Downloaded newer image for docker.io/nginx:latest

接着用 docker run 命令来构建一个 nginx 容器并查看 NGINX 的版本:

sh
$ docker run -it --name my_nginx nginx /bin/bash
root@83c73e8942e2:/# nginx -v
nginx version: nginx/1.19.2

构建镜像

前面已经看到了如何拉取已经构建好的带有定制内容的 Docker 镜像,那么如何修改自己的镜像,并且更新和管理这些镜像呢?构建 Docker 镜像有以下两种方法。

  • 使用 docker commit 命令。
  • 使用 docker build 命令和 Dockerfile 文件。

用 Docker 的 commit 命令创建镜像

创建 Docker 镜像的第一种方法是使用 docker commit 命令。可以将此想象为是在往版本控制系统里提交变更。我们先创建一个容器,并在容器里做出修改,就像修改代码一样,最后再将修改提交为一个新镜像。

先从创建一个新容器开始,这个容器基于前面已经见过的 ubuntu 镜像:

sh
$ docker run -it ubuntu /bin/bash
root@33642e204bb3:/#

接下来,在容器中安装 Apache:

sh
root@33642e204bb3:/# apt update -y && apt install -y apache2

最后,使用 exit 命令从容器里退出,再运行 docker commit 命令:

sh
root@33642e204bb3:/# exit
exit
$ docker commit 33642e204bb3 my_ubuntu/apache
sha256:a35d5689b04569b07c580c8c9d77f12ea6e47d4b3960034dc61876a1b58e4f4d

可以看到,在 docker commit 命令中,指定了要提交的修改过的容器的 ID(可以通过 docker ps -l -q 命令得到最后创建的容器的 ID),以及一个目标镜像仓库和镜像名,这里是 my_ubuntu/apache。

来看看新创建的镜像:

sh
$ docker images my_ubuntu/apache
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
my_ubuntu/apache    latest              a35d5689b045        4 minutes ago       210 MB

也可以在提交镜像时指定更多的数据(包括标签)来详细描述所做的修改。

sh
$ docker commit -a 'helloworld.study' -m 'A new custom image' 33642e204bb3 my_ubuntu/apache2:1.0
sha256:8dcf5ceb53bbc2a8999a1a75ac6376baea284bae6e5e5f02c67944917f1d7072
  • -a:作者
  • -m:提交信息

可以用 docker inspect 命令来查看新创建的镜像的详细信息:

sh
$ docker inspect my_ubuntu/apache2:1.0
[
    {
        "Id": "sha256:8dcf5ceb53bbc2a8999a1a75ac6376baea284bae6e5e5f02c67944917f1d7072",
        "RepoTags": [
            "my_ubuntu/apache2:1.0"
        ],
        "RepoDigests": [],
        "Parent": "sha256:1e4467b07108685c38297025797890f0492c4ec509212e2e4b4822d367fe6bc8",
        "Comment": "A new custom image",
        ……(省略)……
        "Author": "helloworld.study",
……(省略)……

现在就可以使用 docker run 命令从刚创建的新镜像运行一个容器了:

sh
$  docker run -it my_ubuntu/apache2:1.0 /bin/bash
root@a11753b0b1d1:/#

用 Dockerfile 构建镜像

Dockerfile 使用基本的基于 DSL(Domain Specific Language)语法的指令来构建一个 Docker 镜像,推荐使用 Dockerfile 方法来代替 docker commit,因为通过前者来构建镜像更具备可重复性、透明性以及幂等性。

一旦有了 Dockerfile,就可以使用 docker build 命令基于该 Dockerfile 中的指令构建一个新的镜像。

第一个 Dockerfile

例如创建一个包含简单 Web 服务器的 Docker 镜像。

创建一个目录并在里面创建初始的 Dockerfile:

sh
$ mkdir my_nginx
$ cd my_nginx/
$ touch Dockerfile

static_web 目录用来保存 Dockerfile,这个目录就是构建环境(Build Environment),Docker 则称此环境为上下文(Context)或者构建上下文(Build context)。Docker 会在构建镜像时将构建上下文和该上下文中的文件和目录上传到 Docker 守护进程。这样 Docker 守护进程就能直接访问用户想在镜像中存储的任何代码、文件或者其他数据。

在 Dockerfile 中构建一个能作为 Web 服务器的 Docker 镜像:

dockerfile
# Version: 0.0.1
FROM ubuntu:18.04
RUN apt update -y
RUN apt install -y nginx
RUN echo 'Hi, I am in your container' > /var/www/html/index.nginx-debian.html
EXPOSE 80

该 Dockerfile 由一系列指令和参数组成。每条指令,如 FROM,都必须为大写字母,且后面要跟随一个参数:FROM ubuntu:18.04。Dockerfile 中的指令会按顺序从上到下执行,所以应该根据需要合理安排指令的顺序。

每条指令都会创建一个新的镜像层并对镜像进行提交。Docker 大体上按照如下流程执行 Dockerfile 中的指令。

  • Docker 从基础镜像运行一个容器。
  • 执行一条指令,对容器做出修改。
  • 执行类似 docker commit 的操作,提交一个新的镜像层。
  • Docker 再基于刚提交的镜像运行一个新容器。
  • 执行 Dockerfile 中的下一条指令,直到所有指令都执行完毕。

从上面也可以看出,如果 Dockerfile 由于某些原因(如某条指令失败了)没有正常结束,我们也可以得到一个可以使用的镜像。这对调试非常有帮助:可以基于该镜像运行一个具备交互功能的容器,使用最后创建的镜像对为什么用户的指令会失败进行调试。

注意,每个 Dockerfile 的第一条指令必须是 FROMFROM 指令指定一个已经存在的镜像,后续指令都将基于该镜像进行,这个镜像被称为基础镜像(Base Iamge)。

在上面的例子中,我们指定了 ubuntu:18.04 作为新镜像的基础镜像。基于这个 Dockerfile 构建的新镜像将以 Ubuntu 18.04 操作系统为基础。

FROM 指令之后,又指定了两条 RUN 指令。RUN 指令会在当前镜像中运行指定的命令。

在这个例子里,我们通过 RUN 指令更新了已经安装的 APT 仓库并安装了 nginx 包,之后创建了 /var/www/html/index.nginx-debian.html 文件,并将 echo 输出的内容写入到此文件中 。像前面说的那样,每条 RUN 指令都会创建一个新的镜像层,如果该指令执行成功,就会将此镜像层提交,之后继续执行 Dockerfile 中的下一条指令。

提示

默认情况下,RUN 指令会在 Shell 里使用命令包装器 /bin/sh -c 来执行。如果是在一个不支持 Shell 的平台上运行或者不希望在 Shell 中运行(比如避免 Shell 字符串篡改),也可以使用 exec 格式的 RUN 指令。

dockerfile
RUN ["apt", "install", "-y", "nginx"]

在这种方式中,使用一个数组来指定要运行的命令和传递给该命令的每个参数。

接着设置了 EXPOSE 指令,这条指令告诉 Docker 该容器内的应用程序将会使用容器的指定端口。出于安全的原因,Docker 并不会自动打开该端口,而是需要在使用 docker run 运行容器时来指定需要打开哪些端口。

可以指定多个 EXPOSE 指令来向外部公开多个端口。

基于 Dockerfile 构建新镜像

执行 docker build 命令时,Dockerfile 中的所有指令都会被执行并且提交,并且在该命令成功结束后返回一个新镜像。

下面就来看看如何构建一个新镜像:

sh
$ cd my_nginx/
$ docker build -t my_ubuntu/nginx .
Sending build context to Docker daemon 2.048 kB
Step 1/5 : FROM ubuntu:18.04
 ---> 2eb2d388e1a2
Step 2/5 : RUN apt update -y
 ---> Running in c3abb83b7a39
 ---> 12325f044f63
Removing intermediate container c3abb83b7a39
Step 3/5 : RUN apt install -y nginx
 ---> Running in 55c61cba1245
 ---> 02719f8c5896
Removing intermediate container 55c61cba1245
Step 4/5 : RUN echo 'Hi, I am in your container' > /var/www/html/index.nginx-debian.html
 ---> Running in b4395373e291

Hi, I am in your container > /var/www/html/index.nginx-debian.html
 ---> 83fe58e7f1e3
Removing intermediate container b4395373e291
Step 5/5 : EXPOSE 80
 ---> Running in 182c3a8fb4e6
 ---> d6dc96696904
Removing intermediate container 182c3a8fb4e6
Successfully built d6dc96696904
  • -t:为新镜像设置仓库和名称

也可以在构建镜像的过程中为镜像设置一个标签,其使用方法为“镜像名:标签”:

sh
$ docker build -t my_ubuntu/nginx:1.0 .
Sending build context to Docker daemon 2.048 kB
Step 1/5 : FROM ubuntu:18.04
 ---> 2eb2d388e1a2
Step 2/5 : RUN apt update -y
 ---> Using cache
 ---> 12325f044f63
Step 3/5 : RUN apt install -y nginx
 ---> Using cache
 ---> 02719f8c5896
Step 4/5 : RUN echo 'Hi, I am in your container' > /var/www/html/index.nginx-debian.html
 ---> Using cache
 ---> 83fe58e7f1e3
Step 5/5 : EXPOSE 80
 ---> Using cache
 ---> d6dc96696904
Successfully built d6dc96696904

最后的 . 告诉 Docker 到本地目录中去找 Dockerfile 文件。也可以指定一个 Git 仓库的源地址来指定 Dockerfile 的位置。

再回到 docker build 过程。可以看到构建上下文已经上传到了 Docker 守护进程:

sh
Sending build context to Docker daemon 2.048 kB

之后,可以看到 Dockerfile 中的每条指令会被顺序执行,而且作为构建过程的最终结果,返回了新镜像的 ID,即 d6dc96696904 。构建的每一步及其对应指令都会独立运行,并且在输出最终镜像 ID 之前,Docker 会提交每步的构建结果。

构建成功之后,查看新构建的 Docker 镜像:

sh
$ docker images my_ubuntu/nginx
REPOSITORY           TAG                 IMAGE ID            CREATED             SIZE
my_ubuntu/my_nginx   1.0                 d6dc96696904        1 minutes ago      154 MB
my_ubuntu/my_nginx   latest              d6dc96696904        2 minutes ago      154 MB
指令失败时会怎样

下面来看一个例子:假设在第 3 步中将软件包的名字弄错了,比如写成了 ngin。

Dockerfile:

dockerfile
# Version: 0.0.1
FROM ubuntu:18.04
RUN apt update -y
RUN apt install -y ngin
RUN echo 'Hi, I am in your container' > /var/www/html/index.nginx-debian.html
EXPOSE 80

再来运行一遍构建过程并看看当指令失败时会怎样:

sh
$ cd my_nginx/
$ docker build -t my_ubuntu/nginx .
Sending build context to Docker daemon 2.048 kB
Step 1/5 : FROM ubuntu:18.04
 ---> 2eb2d388e1a2
Step 2/5 : RUN apt update -y
 ---> Using cache
 ---> 12325f044f63
Step 3/5 : RUN apt install -y ngin
 ---> Running in cfb6fef490d8
Reading package lists...
Building dependency tree...
Reading state information...
E: Unable to locate package ngin
The command '/bin/sh -c apt install -y ngin' returned a non-zero code: 100

如果这时候需要调试一下这次失败。可以用 docker run 命令来基于这次构建到目前为止已经成功的最后一步创建一个容器。在这个例子里,使用的镜像 ID 是 12325f044f63,在这个容器中可以再次运行 apt install -y ngin,通过进一步调试来找出到底是哪里出错了。一旦解决了这个问题,就可以退出容器,使用正确的包名修改 Dockerfile 文件,之后再尝试进行构建。

sh
$ docker run -it 12325f044f63 /bin/bash
root@58005135a626:/# apt install -y ngin
Reading package lists... Done
Building dependency tree
Reading state information... Done
E: Unable to locate package ngin
Dockerfile 和构建缓存

相信你也发现了,在上面的构建中,使用同样的 Dockerfile 文件再次构建镜像时,在每一步都输出了 Using cache 这句话。也就是说,Docker 会将之前构建时创建的镜像当做缓存并作为新的开始点。当之前的构建步骤没有变化时,这会节省大量的时间。

然而,有些时候需要确保构建过程不会使用缓存。比如,如果已经缓存了前面的第 2 步,即 apt update -y,那么 Docker 将不会再次刷新 APT 包的缓存。这时我们可能需要取得每个包的最新版本。要想略过缓存功能,可以使用 docker build--no-cache 选项。

sh
$ docker build --no-cache -t my_ubuntu/nginx .
查看新镜像

现在来看一下新构建的镜像。这可以使用 docker images 命令来完成:

sh
$ docker images my_ubuntu/nginx
REPOSITORY           TAG                 IMAGE ID            CREATED             SIZE
my_ubuntu/my_nginx   1.0                 d6dc96696904        1 minutes ago      154 MB
my_ubuntu/my_nginx   latest              d6dc96696904        2 minutes ago      154 MB

如果想深入探求镜像是如何构建出来的,可以使用 docker history 命令:

sh
$ docker history d6dc96696904
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
d6dc96696904        23 hours ago        /bin/sh -c #(nop)  EXPOSE 80/tcp                0 B
83fe58e7f1e3        23 hours ago        /bin/sh -c echo 'Hi, I am in your containe...   0 B
02719f8c5896        23 hours ago        /bin/sh -c apt install -y nginx                 60.3 MB
12325f044f63        23 hours ago        /bin/sh -c apt update -y                           29.2 MB
2eb2d388e1a2        4 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0 B

从上面的结果可以看到新构建的 my_ubuntu/nginx 镜像的每一层,以及创建这些层的 Dockerfile 指令。

从新镜像启动容器

现在就可以基于新构建的镜像启动一个新容器,来检查一下构建工作是否正常了。

sh
$ docker run -d -p 80 --name my_nginx my_ubuntu/nginx nginx -g 'daemon off;'
4923c9393ccb894d1d25125e8e0128591ec6a41f19817b1599b2117ec376bc7c
  • -d:Docker 以分离(detached)的方式在后台运行(这种方式非常适合运行类似Nginx守护进程这样的需要长时间运行的进程)
  • -p:Docker 在运行时应该公开哪些网络端口给外部(宿主机),亦可以映射到 Docker 宿主机的某一特定端口上

提示

运行一个容器时,Docker 可以通过两种方法来在宿主机上分配端口。

  • Docker 可以在宿主机上随机选择一个位于 32768~61000 的一个比较大的端口号来映射到容器中的 80 端口上。
  • 可以在 Docker 宿主机中指定一个具体的端口号来映射到容器中的 80 端口上。

docker run -p 80 命令表示在 Docker 宿主机上随机打开一个端口,这个端口会连接到容器中的 80 端口上。

最后指定了需要在容器中运行的命令:nginx -g 'daemon off;'。这将以前台运行的方式启动 Nginx,来作为 Web 服务器。

使用 docker ps 命令来看一下容器的端口分配情况:

sh
$  docker ps -l
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                   NAMES
4923c9393ccb        my_ubuntu/nginx     "nginx -g 'daemon ..."   1 minutes ago       Up 1 minutes        0.0.0.0:32768->80/tcp   my_nginx

可以看到,容器中的 80 端口被映射到了宿主机的 32768 上。

还可以通过 docker port 命令来查看容器的端口映射情况:

sh
$ docker port my_nginx
80/tcp -> 0.0.0.0:32768

-p 选项还可以将容器中的端口映射到 Docker 宿主机的某一特定端口上。例如,将容器内的80端口绑定到本地宿主机的80端口上:

sh
$ docker run -d -p 80:80 --name my_nginx my_ubuntu/nginx nginx -g 'daemon off;'
a4fd854d6637cf5fb1906acebac9d114bed9ee596237a852f134a995c14d1e5e

很明显,必须非常小心地使用这种直接绑定的做法:如果要运行多个容器,只有一个容器能成功地将端口绑定到本地宿主机上。这将会限制 Docker 的灵活性。

也可以将端口绑定限制在特定的网络接口(即 IP 地址)上:

sh
$ docker run -d -p 127.0.0.1:80:80 --name my_nginx my_ubuntu/nginx nginx -g 'daemon off;'
ac112930f4d16640cfeaa0651ccde26ec47444a1985f3e519e898b1d8dc4d231

也可以使用类似的方式将容器内的 80 端口绑定到一个宿主机的随机端口上:

sh
$ docker run -d -p 127.0.0.1::80 --name my_nginx my_ubuntu/nginx nginx -g 'daemon off;'
b3b9cd88bcfaeb4d4fd3e68ee19a5479dc258c8e79a0b315b8db207ea48fd76c

Docker 还提供了一个更简单的方式,即 -P 选项,该选项可以用来对外公开在 Dockerfile 中通过 EXPOSE 指令公开的所有端口:

sh
$ docker run -d -P --name my_nginx my_ubuntu/nginx nginx -g 'daemon off;'
0fab8bc49537302611ee0f442ac1746cf17b4967ac87c548cf9891eff0770f51

有了端口号,就可以使用本地宿主机的 IP 地址或者 127.0.0.1 的 localhost 连接到运行中的容器,查看 Web 服务器内容了:

sh
$ docker port my_nginx
80/tcp -> 0.0.0.0:32774
$ curl localhost:32774
Hi, I am in your container

Dockerfile 指令

我们已经看过了一些 Dockerfile 中可用的指令,如 RUNEXPOSE。但是,实际上还可以在 Dockerfile 中放入很多其他指令,这些指令包括 CMDENTRYPOINTADDCOPYVOLUMEWORKDIRUSERONBUILDLABELSTOPSIGNALARGENV 等。

可以在https://docs.docker.com/engine/reference/builder/查看 Dockerfile 中可以使用的全部指令的清单。

CMD
dockerfile
CMD command param1 param2(shell 方式)
CMD ["executable", "param1", "param2"](exec 方式)
CMD ["param1","param2"](该写法是为 ENTRYPOINT 指令指定的程序提供默认参数)

CMD 指令用于指定一个容器启动时要运行的命令。这有点儿类似于 RUN 指令,只是 RUN 指令是指定镜像被构建时要运行的命令,而 CMD 是指定容器被启动时要运行的命令。

dockerfile
CMD echo 'Hello, CMD!'

CMD ["echo", "'Hello, CMD!'"]

相当于

sh
$ docker run my_ubuntu/nginx echo 'Hello, CMD!'

警告

还需牢记,使用 docker run 命令可以覆盖 CMD 指令。如果在 Dockerfile 里指定了 CMD 指令,而同时在 docker run 命令行中也指定了要运行的命令,命令行中指定的命令会覆盖 Dockerfile 中的 CMD 指令。

还要注意的是,在 Dockerfile 中只能指定一条 CMD 指令。如果指定了多条 CMD 指令,也只有最后一条 CMD 指令会被使用。如果想在启动容器时运行多个进程或者多条命令,可以考虑使用类似 Supervisor 这样的服务管理工具。

ENTRYPOINT
dockerfile
ENTRYPOINT command param1 param2(shell 方式)
ENTRYPOINT ["executable", "param1", "param2"](exec 方式)

ENTRYPOINT 指令与 CMD 指令非常类似,也很容易和 CMD 指令弄混。docker run 命令行中可以覆盖 CMD 指令,但不容易覆盖 ENTRYPOINT 指令。实际上,docker run 命令行中指定的任何参数都会被当做参数再次传递给 ENTRYPOINT 指令中指定的命令。

dockerfile
ENTRYPOINT echo 'Hello, ENTRYPOINT!'

ENTRYPOINT ["echo", "'Hello, ENTRYPOINT!'"]

相当于

sh
$ docker run my_ubuntu/nginx echo 'Hello, ENTRYPOINT!'

这样就跟 CMD 指令没有区别了。不同的地方是 ENTRYPOINT 指令还能做到动态传参。

例如,有这么一个 Dockerfile:

dockerfile
FROM ubuntu:18.04
ENTRYPOINT ["echo"]
CMD ["Hello, ENTRYPOINT!"]

构建一个镜像:

sh
$ docker build -t test_entrypoint .

从构建的镜像启动一个容器:

sh
$ docker run test_entrypoint
Hello, ENTRYPOINT!

也可以自定义输出内容:

sh
$ docker run test_entrypoint 'Hello, World!'
Hello, World!

提示

想要覆盖 ENTRYPOINT 指令怎么办?可以在运行时通过 docker run 中的 --entrypoint 选项来覆盖 ENTRYPOINT 指令。

例如:

sh
$ docker run -it --entrypoint /bin/bash test_entrypoint
root@393b3d45ff74:/#
WORKDIR
dockerfile
WORKDIR /path/to/workdir(工作目录)

WORKDIR 指令用来在从镜像创建一个新容器时,在容器内部设置一个工作目录,ENTRYPOINTdocker run [COMMAND]CMD 指定的程序会在这个目录下执行。

例如,有这么一个 Dockerfile:

dockerfile
FROM ubuntu:18.04
WORKDIR /var/www/html
ENTRYPOINT ["pwd"]

构建一个镜像:

sh
$ docker build -t test_workdir .

从构建的镜像启动一个容器:

sh
$ docker run test_workdir
/var/www/html

提示

可以在运行时通过 docker run 中的 -w 选项来覆盖工作目录。

例如:

sh
$ docker run -w /var/log test_workdir pwd
/var/log
ENV
dockerfile
ENV <key> <value>
ENV <key>=<value> ...

ENV 指令用来在镜像构建过程中设置环境变量(可以同时设置多个),设置的环境环境可以在其他指令中使用。

例如,有这么一个 Dockerfile:

dockerfile
FROM ubuntu:18.04
ENV MY_PATH=/var/www/html
WORKDIR $MY_PATH
CMD ["pwd"]

构建一个镜像:

sh
$ docker build -t test_env .

从构建的镜像启动一个容器:

sh
$ docker run test_env
/var/www/html

这些环境变量还会被持久保存到从镜像创建的任何容器中:

sh
$ docker run -it test_env /bin/bash
root@fc924776ad40:/var/www/html# echo $MY_PATH
/var/www/html

提示

可以在运行时通过 docker run 中的 -e 选项来传递环境变量,只不过这些变量只会在运行时有效。

例如:

sh
$ docker run -it -e MY_PORT=80 test_env /bin/bash
root@e908140b1ec2:/var/www/html# echo $MY_PORT
80
USER
dockerfile
USER <user>[:<group>]
USER <UID>[:<GID>]

USER 指令用来指定该镜像会以指定用户和用户组去运行,相当于进入容器后 su [USER](用户和用户组必须提前已经存在)。

例如,有这么一个 Dockerfile:

dockerfile
FROM ubuntu:18.04
# 假如 nginx 用户已存在
USER nginx
CMD ["whoami"]

构建一个镜像:

sh
$ docker build -t test_user .

从构建的镜像启动一个容器:

sh
$ docker run test_user
nginx

提示

可以在运行时通过 docker run 中的 -u 选项来指定值。

例如:

sh
$ docker run -u nginx
nginx
VOLUME
dockerfile
VOLUME /data
VOLUME ["/data"]

VOLUME 指令用来向基于镜像创建的容器添加卷。一个卷是可以存在于一个或者多个容器内的特定的目录,这个目录可以绕过联合文件系统,并提供如下共享数据或者对数据进行持久化的功能。

  • 卷可以在容器间共享和重用。
  • 一个容器可以不是必须和其他容器共享卷。
  • 对卷的修改是立时生效的。
  • 对卷的修改不会对更新镜像产生影响。
  • 卷会一直存在直到没有任何容器再使用它。

卷功能让我们可以将数据(如源代码)、数据库或者其他内容添加到镜像中而不是将这些内容提交到镜像中,并且允许我们在多个容器间共享这些内容。我们可以利用此功能来测试容器和内部的应用程序代码,管理日志,或者处理容器内部的数据库。

COPY
dockerfile
COPY [--chown=<user>:<group>] <src>... <dest>
COPY [--chown=<user>:<group>] ["<src>",... "<dest>"]

COPY 指令用来将构建环境(Dockerfile 所在目录)下的文件和目录复制到镜像中。比如,在安装一个应用程序时。COPY 指令需要源文件位置和目的文件位置两个参数。

dockerfile
COPY hello.log /world/

这里的 COPY 指令将会将构建目录下的 hello.log 文件复制到镜像中的 /world/hello.log

COPY 文件时,Docker 通过目的地址参数末尾的字符来判断文件源是目录还是文件。如果目的地址以 / 结尾,那么 Docker 就认为源位置指向的是目录。如果目的地址不是以 / 结尾,那么 Docker 就认为源位置指向的是文件。

最后,如果目的位置不存在,Docker 将会自动创建所有需要的目录结构,就像 mkdir -p 命令那样。新创建的文件和目录的文件权限为 0755,并且 UID 和 GID 都是 0。

不能对构建环境之外的文件或目录进行 COPY 操作

因为构建环境将会上传到 Docker 守护进程,而复制是在 Docker 守护进程中进行的。任何位于构建环境之外的东西都是不可用的。COPY 指令的目的位置则必须是容器内部的一个绝对路径。

  • 相对路径

    dockerfile
    FROM ubuntu:18.04
    COPY ../hello.log /

    构建 Docker 镜像时:

    sh
    $ docker build -t test_copy .
    Sending build context to Docker daemon  5.12 kB
    Step 1/2 : FROM ubuntu:18.04
     ---> 2eb2d388e1a2
    Step 2/2 : COPY ../hello.log /
    Forbidden path outside the build context: ../hello.log ()
  • 绝对路径

    dockerfile
    FROM ubuntu:18.04
    COPY /hello.log /

    构建 Docker 镜像时:

    sh
    $ docker build -t test_copy .
    Sending build context to Docker daemon 4.096 kB
    Step 1/2 : FROM ubuntu:18.04
     ---> 2eb2d388e1a2
    Step 2/2 : COPY /hello.log /
    lstat hello.log: no such file or directory

    在宿主机中明明有指定的目录或文件,为什么说不存在呢?

    这是因为,在 COPY 指令中,要复制的源文件的绝对路径是以上下文当前目录为根目录的。也就是说,/hello.log 指的是 Dockerfile 文件所在目录中的 hello.log

ADD
dockerfile
ADD [--chown=<user>:<group>] <src>... <dest>
ADD [--chown=<user>:<group>] ["<src>",... "<dest>"]

ADD 指令非常类似于 COPY,它们根本的不同是 COPY 只关心在构建上下文中复制本地文件,而 ADD 还会去做文件提取(Extraction)和解压(Decompression)的工作。

如果将一个归档文件(合法的归档文件包括 gzipbzip2xz)指定为源文件,Docker 会自动将归档文件解开(Unpack)。

dockerfile
ADD hello.tar.gz /world/

这条命令会将归档文件 hello.tar.gz 解压到 /world/ 目录下。Docker 解压归档文件的行为和使用带 -x 选项的 tar 命令一样:该指令执行后的输出是原目的目录已经存在的内容加上归档文件中的内容。如果目的位置的目录下已经存在了和归档文件同名的文件或者目录,那么目的位置中的文件或者目录不会被覆盖。

还有一个不同的地方是,ADD 指令中的文件源还可以使用 URL 的格式:

dockerfile
ADD http://nginx.org/download/nginx-1.18.0.tar.gz /nginx/
LABEL
dockerfile
LABEL <key>=<value> <key>=<value> <key>=<value> ...

LABEL 指令用于为 Docker 镜像添加元数据。元数据以键值对的形式展现。

例如,有这么一个 Dockerfile:

dockerfile
FROM ubuntu:18.04
LABEL version=1.0
LABEL name=test_label localtion=china

构建一个镜像:

sh
$ docker build -t test_label .

可以通过 docker inspect 命令来查看 Docker 镜像中的标签信息:

sh
$ docker inspect test_label
[
    {
        ...(省略)...
        "ContainerConfig": {
            ...(省略)...
            "Labels": {
                "localtion": "china",
                "name": "test_label",
                "version": "1.0"
            }
        },
        "DockerVersion": "1.13.1",
...(省略)...

这里可以看到前面用 LABEL 指令定义的元数据信息。

可以在每一条指令中指定一个元数据,或者指定多个元数据,不同的元数据之间用空格分隔。推荐将所有的元数据都放到一条LABEL指令中,以防止不同的元数据指令创建过多镜像层。

STOPSIGNAL
dockerfile
STOPSIGNAL signal

STOPSIGNAL 指令用来设置停止容器时发送什么系统调用信号给容器。这个信号必须是内核系统调用表中合法的数,如 9,或者 SIGNAME 格式中的信号名称,如 SIGKILL

ARG
dockerfile
ARG <name>[=<default value>]

ARG 指令用来定义可以在 docker build 命令运行时传递给构建运行时的变量,只需要在构建时使用 --build-arg 选项即可。在构建时只能指定在 Dockerfile 文件中定义过的参数。

例如,有这么一个 Dockerfile:

dockerfile
FROM ubuntu:18.04
ARG MY_PATH=/var/www/html
WORKDIR $MY_PATH
CMD ["pwd"]

构建一个镜像:

sh
$ docker build -t test_arg .

从构建的镜像启动一个容器:

sh
$ docker run test_arg
/var/www/html

在构建命令 docker build 中可以用 --build-arg <varname>=<value> 选项来覆盖默认的值:

sh
$ docker build --build-arg MY_PATH=/var -t test_arg .
$ docker run test_arg
/var

ENV 指令不同的是,这些环境变量不会被持久保存到从镜像创建的任何容器中:

sh
$ docker run -it test_arg /bin/bash
root@fc924776ad40:/var/www/html# echo $MY_PATH
(什么也没有)

提示

Docker 预定义了一组 ARG 变量,可以在构建时直接使用,而不必再到 Dockerfile 中自行定义。

dockerfile
HTTP_PROXY
http_proxy
HTTPS_PROXY
https_proxy
FTP_PROXY
ftp_proxy
NO_PROXY
no_proxy

要想使用这些预定义的变量,只需要给 docker build 命令传递 --build-arg <varname>=<value> 选项就可以了。

ONBUILD
dockerfile
ONBUILD <INSTRUCTION>

ONBUILD 指令能为镜像添加触发器(Trigger)。当一个镜像被用做其他镜像的基础镜像时,该镜像中的触发器将会被执行。

简单的说,就是 Dockerfile 里用 ONBUILD 指定的命令,在本次构建镜像的过程中不会执行(假设镜像为 test_onbuild_base)。当有新的 Dockerfile 使用了之前构建的镜像 FROM test_onbuild_base,在使用新的 Dockerfile 构建镜像时,会先执行 test_onbuild_base 中的 Dockerfile 里的 ONBUILD 指定的命令。

例如,有这么一个 Dockerfile:

dockerfile
FROM ubuntu:18.04
ONBUILD RUN ["echo", "我是基础镜像里的命令"]

构建一个镜像:

sh
$ docker build -t test_onbuild_base .

将上面构建好的镜像作为基础镜像:

dockerfile
FROM test_onbuild_base

构建时:

sh
$ docker build -t test_onbuild .
Sending build context to Docker daemon 2.048 kB
Step 1/1 : FROM test_onbuild_base
# Executing 1 build trigger...
Step 1/1 : RUN echo Hi, I am in the base image
 ---> Running in bb9d117f1c5d

Hi, I am in the base image
 ---> 1347100c5538
Removing intermediate container bb9d117f1c5d
Successfully built 1347100c5538

可以清楚地看到,在 FROM 指令之后,Docker 插入了一条 RUN 指令,这条 RUN 指令就是在 ONBUILD 触发器中指定的。

ONBUILD 触发器会按照在父镜像中指定的顺序执行,并且只能被继承一次(也就是说只能在子镜像中执行,而不会在孙子镜像中执行)。如果再基于 test_onbuild 构建一个镜像,则新镜像是 test_onbuild_base 的孙子镜像,因此在该镜像的构建过程中,ONBUILD 触发器是不会被执行的。

警告

这里有好几条指令是不能用在 ONBUILD 指令中的,包括 FROMMAINTAINERONBUILD 本身。之所以这么规定是为了防止在 Dockerfile 构建过程中产生递归调用的问题。

将镜像推送到 Docker Hub

镜像构建完毕之后,我们也可以将它上传到 Docker Hub 上面去,这样其他人就能使用这个镜像了。比如,我们可以在组织内共享这个镜像,或者完全公开这个镜像。

创建 Docker Hub 账号

为了完成这项工作,需要在 Docker Hub 上创建一个账号,可以从 https://hub.docker.com/signup/ 加入 Docker Hub:

(创建Docker Hub账号。图片来源:自己截得)

首先需要注册一个账号,并在注册之后通过收到的确认邮件进行激活。

下面就可以测试刚才注册的账号是否能正常工作了。

要登录到 Docker Hub,可以使用 docker login 命令:

sh
$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: panxingcheng
Password: 12345
Login Succeeded

这条命令将会完成登录到 Docker Hub 的工作,并将认证信息保存起来以供后面使用。可以使用 docker logout 命令从一个 Registry 服务器退出。

提示

用户的个人认证信息将会保存到 $HOME/.docker/config.json 文件中。

推送到 Docker Hub

通过 docker push 命令将镜像推送到 Docker Hub。

现在就来试一试如何推送:

sh
$ docker push my_ubuntu/nginx:latest
The push refers to a repository [docker.io/my_ubuntu/nginx]
1b664d8deb97: Preparing
d234cb263e1d: Preparing
4a31c6997c0b: Preparing
8682f9a74649: Preparing
d3a6da143c91: Preparing
83f4287e1f04: Waiting
7ef368776582: Waiting
denied: requested access to the resource is denied

推送镜像并没有成功,原因是目标镜像仓库与登录名不匹配。这个时候就需要使用 docker tag 命令将某一本地镜像归入某一仓库。

将本地镜像 my_ubuntu/nginx:latest 加入到 panxingcheng 仓库:

sh
$ docker tag my_ubuntu/nginx:latest panxingcheng/nginx:1.0

再次尝试推送到 Docker Hub:

sh
$ docker push panxingcheng/nginx:1.0
The push refers to a repository [docker.io/panxingcheng/nginx]
1b664d8deb97: Pushed
d234cb263e1d: Pushed
4a31c6997c0b: Pushed
8682f9a74649: Pushed
d3a6da143c91: Pushed
83f4287e1f04: Pushed
7ef368776582: Pushed
1.0: digest: sha256:b61113e9d645c0b7c98f530555a4ce82f547273d83807914d8b83b18c82069a9 size: 1783

这次成功地将镜像推送到了 Docker Hub。可以在 Docker Hub 看到刚才上传的镜像。

(Docker Hub 上的镜像。图片来源:自己截得)

自动构建

除了从命令行构建和推送镜像,Docker Hub 还允许我们定义自动构建(Automated Builds)。为了使用自动构建,只需要将 GitHub 或 BitBucket 中含有 Dockerfile 文件的仓库连接到 Docker Hub 即可。向这个代码仓库推送代码时,将会触发一次镜像构建活动并创建一个新镜像。在之前该工作机制也被称为可信构建(Trusted Build)。

在 Docker Hub 中添加自动构建任务的步骤:

  1. 将 GitHub 或者 BitBucket 账号连接到 Docker Hub。

    具体操作是,打开 Docker Hub,登录后单击个人信息链接,之后单击 Account Settings -> Linked Accounts 按钮:

    (链接到 GitHub。图片来源:自己截得)

    初次链接到 GitHub 会有一个授权操作:

    (GitHub 授权。图片来源:自己截得)

  2. 在 GitHub 上建立一个含有 Dockerfile 文件的仓库。

    比如,在 GitHub 上建立一个名为 docker-nginx 的仓库:

    (GitHub 上的 Dockerfile。图片来源:自己截得)

  3. 对自动构建进行配置。

    • 在新的 Docker Hub 仓库配置自动构建

      单击 Create Repository 按钮,将会在此页面看到 Build Settings:

      单击 Build Settings 中的 GitHub 图标,选择用来进行自动构建的组织和仓库:

      之后单击 BUILD RULES 旁边的 “+”,开始对自动构建进行配置:

      最后,单击 Create & Build 按钮来将自动构建添加到新的 Docker Hub 仓库中。

    • 在已有的 Docker Hub 仓库配置自动构建

      选择一个已有的 Docker Hub 仓库,进入 Builds 页面并单击 GitHub 图标:

      单击 Builds 中的 GitHub 图标,选择用来进行自动构建的组织和仓库:

      为每次自动构建过程创建的镜像指定一个标签,并指定 Dockerfile 的位置。默认的位置为代码仓库的根目录下,但是也可以随意设置该路径:

      最后,单击 Save & Build 按钮来将自动构建添加到已有的 Docker Hub 仓库中。

    我们会看到自动构建已经被提交了。

    在 Recent Builds 可以查看最近一次构建的状态,包括标准输出的日志,里面记录了构建过程以及任何的错误。

删除镜像

如果不再需要一个镜像了,也可以将它删除。

可以使用 docker rmi 命令来删除一个镜像:

sh
$ docker rmi my_ubuntu/nginx
Untagged: my_ubuntu/nginx:latest
Untagged: panxingcheng/nginx@sha256:b61113e9d645c0b7c98f530555a4ce82f547273d83807914d8b83b18c82069a9

还可以在命令行中指定一个镜像名列表来删除多个镜像:

sh
$ docker rmi test_onbuild test_onbuild_base
Untagged: test_onbuild:latest
Deleted: sha256:1347100c5538d99a1aa8457f718f6aceb94e37511634a856375f788f708dd1ba
Deleted: sha256:2ef14457a70ae1b610e1349e730c732ec9b24fa29dade3a3f900793573208bb5
Untagged: test_onbuild_base:latest
Deleted: sha256:24e1bf4402837172deb45d05659bd202f97f2784e944661bdcef87f27218228a

删除全部镜像:

sh
$ docker rmi `docker images -a -q`

在测试中使用 Docker

接下来试着在实际开发和测试过程中使用 Docker。

使用 Docker 测试静态网站

下面从将 Nginx Web 服务器安装到容器来架构一个简单的网站开始。这个网站暂且命名为 Sample。

Sample 网站的初始 Dockerfile

为了完成网站开发,从这个简单的 Dockerfile 开始。先来创建一个目录,保存 Dockerfile:

sh
$ mkdir sample
$ cd sample
$ touch Dockerfile

现在还需要一些 Nginx 配置文件,才能运行这个网站。首先在这个示例所在的目录里创建一个名为 nginx 的目录,用来存放 global.confnginx.conf 配置文件。

sh
$ mkdir nginx && cd nginx
$ touch global.conf
$ touch nginx.conf
$ cd ..

global.conf 配置文件内容:

nginx
server {
	listen          0.0.0.0:80; # 监听 80 端口
	server_name     _;

	root            /var/www/html/website; # 设置网络服务的根路径
	index           index.html index.htm;

	access_log      /var/log/nginx/default_access.log;
	error_log       /var/log/nginx/default_error.log;
}

nginx.conf 配置文件内容:

nginx
user www-data;
worker_processes 4;
pid /run/nginx.pid;
daemon off; # 将 Nginx 配置为非守护进程

events {  }

http {
	sendfile on;
	tcp_nopush on;
	tcp_nodelay on;
	keepalive_timeout 65;
	types_hash_max_size 2048;
	include /etc/nginx/mime.types;
	default_type application/octet-stream;
	access_log /var/log/nginx/access.log;
	error_log /var/log/nginx/error.log;
	gzip on;
	gzip_disable "msie6";
	include /etc/nginx/conf.d/*.conf;
}

将要为 Sample 网站创建的 Dockerfile:

dockerfile
FROM ubuntu:18.04
RUN apt update -y && apt install -y nginx
RUN mkdir -p /var/www/html/website
ADD nginx/global.conf /etc/nginx/conf.d/
ADD nginx/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80

这个简单的 Dockerfile 内容包括以下几项:

  • 安装 Nginx。
  • 在容器中创建一个目录 /var/www/html/website/
  • 将本地文件的 Nginx 配置文件添加到镜像中。
  • 公开镜像的 80 端口。

构建 Sample 网站和 Nginx 镜像

利用之前的 Dockerfile,可以用 docker build 命令构建出新的镜像,并将这个镜像命名为 my_ubuntu/nginx_sample

sh
$ docker build -t my_ubuntu/nginx_sample .

使用 docker history 命令查看构建新镜像的步骤和层级:

sh
docker history my_ubuntu/nginx_sample
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
b2fc63cf7a7f        2 hours ago         /bin/sh -c #(nop)  EXPOSE 80/tcp                0 B    
5d3992aedcac        2 hours ago         /bin/sh -c #(nop) ADD file:ca1cb1ce1760f99...   440 B  
965d05cb08c3        2 hours ago         /bin/sh -c #(nop) ADD file:7ebd74892f3120f...   296 B  
4458e80225ac        2 hours ago         /bin/sh -c mkdir -p /var/www/html/website       0 B    
666ac759749a        2 hours ago         /bin/sh -c apt update -y && apt install -y nginx   89.6 MB
2eb2d388e1a2        5 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0 B    
<missing>           5 weeks ago         /bin/sh -c mkdir -p /run/systemd && echo '...   7 B    
<missing>           5 weeks ago         /bin/sh -c set -xe   && echo '#!/bin/sh' >...   745 B  
<missing>           5 weeks ago         /bin/sh -c [ -z "$(apt-get indextargets)" ]     987 kB 
<missing>           5 weeks ago         /bin/sh -c #(nop) ADD file:7d9bbf45a5b2510...   63.2 MB

history 命令从新构建的 my_ubuntu/nginx_sample 镜像的最后一层开始,追溯到最开始的父镜像 ubuntu:18.04。这个命令也展示了每步之间创建的新层,以及创建这个层所使用的 Dockerfile 里的指令。

从 Sample 网站和 Nginx 镜像构建容器

现在可以使用 my_ubuntu/nginx_sample 镜像,并开始从这个镜像构建可以用来测试 Sample 网站的容器。为此,需要添加 Sample 网站的代码到 sample 目录:

sh
$ mkdir website && cd website
$ touch index.html
$ cd ..

index.html 代码文件内容:

html
<!DOCTYPE html>
<html>
	<head>
		<title></title>
	</head>
	<body>
		<h1>This is a test website</h1>
	</body>
</html>

使用 docker run 命令来运行一个容器:

sh
$ docker run -d -p 80 --name website -v $PWD/website:/var/www/html/website my_ubuntu/nginx_sample nginx
  • -v:将宿主机的目录作为卷,挂载到容器里。

    格式:[宿主机目录]:[容器目录]:[rw/ro(读写/只读权限)]

    这两个目录用 : 分隔。如果容器目录不存在,Docker 会自动创建一个。

也可以通过在目录后面加上 rw 或者 ro 来指定容器内目录的读写状态:

sh
$ docker run -d -p 80 --name website -v $PWD/website:/var/www/html/website:ro my_ubuntu/nginx_sample nginx

这将使目的目录 /var/www/html/website 变成只读状态。

提示

卷在 Docker 里非常重要,也很有用。卷是在一个或者多个容器内被选定的目录,可以绕过分层的联合文件系统(Union File System),为 Docker 提供持久数据或者共享数据。这意味着对卷的修改会直接生效,并绕过镜像。当提交或者创建镜像时,卷不被包含在镜像里。

当我们因为某些原因不想把应用或者代码构建到镜像中时,就体现出卷的价值了。例如:

  • 希望同时对代码做开发和测试;
  • 代码改动很频繁,不想在开发过程中重构镜像;
  • 希望在多个容器间共享代码。

现在,如果使用 docker ps 命令查看正在运行的容器,可以看到名为 website 的容器正处于活跃状态,容器的 80 端口被映射到宿主机的 49161 端口:

sh
$ docker ps
CONTAINER ID        IMAGE                    COMMAND                  CREATED              STATUS              PORTS                   NAMES
c9e2c58ad6bb        my_ubuntu/nginx_sample   "nginx"                  About a minute ago   Up About a minute   0.0.0.0:49161->80/tcp   website

在 Docker 的宿主机上浏览 49161 端口打开这个 Sample 网站:

修改网站

如果要修改网站,该怎么办?可以直接打开本地宿主机的 website 目录下的 index.html 文件并修改:

html
<!DOCTYPE html>
<html>
	<head>
		<title></title>
	</head>
	<body>
		<h1>This is a test website for Docker.</h1>
	</body>
</html>

刷新一下浏览器,看看现在的网站是什么样的:

可以看到,Sample 网站已经更新了。显然这个修改太简单了,不过可以看出,更复杂的修改也并不困难。更重要的是,正在测试网站的运行环境,完全是生产环境里的真实状态。现在可以给每个用于生产的网站服务环境(如 Apache、Nginx)配置一个容器,给不同开发框架的运行环境(如 PHP 或者 Node.js)配置一个容器,或者给后端数据库配置一个容器,等等。

使用 Docker 构建并测试 Web 应用程序

下面将要构建一个基于 Node.js 的 Web 应用程序,而不是静态网站,然后将基于 Docker 来对这个应用进行测试。这个网站暂且命名为 Express。

这个应用程序接收输入的 URL 参数,并以 JSON 格式输出到客户端。

构建 Express 应用程序

先来创建一个 express 目录,用来存放应用程序的代码,以及构建时所需的所有相关文件:

sh
$ mkdir express
$ cd express
$ touch Dockerfile

在 express 目录下,从 Dockerfile 开始,构建一个基础镜像,并用这个镜像来开发 Express Web 应用程序:

dockerfile
FROM ubuntu:18.04
RUN apt update -y && apt install -y nodejs
RUN mkdir -p /opt/webapp
EXPOSE 3000
ENTRYPOINT ["node"]
CMD ["/opt/webapp/app.js"]

这个简单的 Dockerfile 内容包括以下几项:

  • 安装 Node.js。
  • 在容器中创建一个目录 /opt/webapp/
  • 公开镜像的 3000 端口。
  • 在命令行上使用 node 命令。
  • 指定 /opt/webapp/app.js 作为 Web 应用程序的启动文件。

现在使用 docker build 命令来构建新的镜像:

sh
$ docker build -t my_ubuntu/nodejs_express .

创建 Express 容器

创建好镜像后,开始创建 Express Web 应用程序的源代码,源代码保存在 express/webapp/app.js

sh
$ mkdir webapp
$ cd webapp/
$ touch app.js

app.js 代码文件内容:

js
const http = require('http');

const hostname = '0.0.0.0';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  // 解析 url 参数
  var params = url.parse(req.url, true).query;
  res.end(JSON.stringify(params));
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

使用 docker run 命令来运行一个容器。要启动容器,需要将 express 这个目录下的源代码通过卷挂载到容器中去:

sh
$ docker run -d -p 3000 --name webapp -v $PWD/webapp:/opt/webapp my_ubuntu/nodejs_express

这里从 my_ubuntu/nodejs_express 镜像创建了一个新的名为 webapp 的容器。指定了一个新卷,使用存放 Express Web 应用程序的 webapp 目录 ,并将这个卷挂载到在 Dockerfile 里创建的目录 /opt/webapp

可以使用 docker logs 命令查看被执行的命令都输出了什么:

sh
$ docker logs -f webapp
Server running at http://127.0.0.1:3000/

可以使用 docker top 命令查看 Docker 容器里正在运行的进程:

sh
$ docker top webapp
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
root                18709               18693               0                   15:44               ?                   00:00:00            node /opt/webapp/app.js

查看一下 3000 端口映射到本地宿主机的哪个端口:

sh
$ docker port webapp 3000
0.0.0.0:32805

现在可以使用 curl 命令来测试这个应用程序了:

sh
$ curl -i localhost:32805/?name=hello\&age=18
HTTP/1.1 200 OK
Content-Type: text/plain
Date: Thu, 03 Sep 2020 09:04:58 GMT
Connection: keep-alive
Content-Length: 27

{"name":"hello","age":"18"}

可以看到,我们给 Express 应用程序传入了一些 URL 参数,并看到这些参数转化成 JSON 格式后的输出:{"name":"hello","age":"18"}

成功!然后试试看,能不能通过连接到运行在另一个容器里的服务,把当前的应用程序容器扩展为真正的应用程序栈。

扩展 Express 应用程序来使用 Redis

现在将要扩展 Express 应用程序,加入 Redis 后端数据库,并在 Redis 数据库中存储输入的 URL 参数。为了达到这个目的,需要创建一个运行 Redis 数据库的镜像和容器,还需要编写一个新版本的 Express 应用程序。之后,要利用 Docker 的特性来关联两个容器。

  1. 构建 Redis 数据库镜像 为了构建 Redis 数据库,要创建一个新的镜像。需要在 express 目录下创建一个 redis 目录,用来保存构建 Redis 容器所需的所有相关文件:

    sh
    $ mkdir -p express/redis
    $ cd express/redis/
    $ touch Dockerfile

    express/redis 目录中,从 Redis 镜像的一个 Dockerfile 开始:

    dockerfile
    FROM ubuntu:18.04
    RUN apt update -y && apt install -y redis-server
    EXPOSE 6379
    ENTRYPOINT ["/usr/bin/redis-server", "--protected-mode no"]

    现在来构建这个镜像,命名为 my_ubuntu/redis:

    sh
    $ docker build -t my_ubuntu/redis .

    现在从这个新镜像构建容器:

    sh
    $ docker run -d -p 6379 --name redis my_ubuntu/redis

    看看这个端口映射到宿主机的哪个端口:

    sh
    $ docker port redis 6379
    0.0.0.0:32771

    Redis 的端口映射到了 32771 端口。试着连接到这个 Redis 实例。

    需要在本地安装 Redis 客户端做测试。在 CentOS 系统上,客户端程序一般在 redis 包里:

    sh
    $ yum update -y && yum install -y redis

    然后,可以使用 redis-cli 命令来确认 Redis 服务器工作是否正常:

    sh
    $ redis-cli -h 127.0.0.1 -p 32771
    127.0.0.1:32771> QUIT

    这里使用 Redis 客户端连接到 127.0.0.1 的 32771 端口,验证了 Redis 服务器正在正常工作。可以使用 quit 命令来退出 Redis CLI 接口。

  2. 升级 Express 应用程序 这个升级版中增加了连接 Redis 的配置。

    新的 Dockerfile 文件中,添加了使用 RUN 指令下载 NPM 并安装 redis 模块的内容:

    dockerfile
    FROM ubuntu:18.04
    RUN apt update -y && apt install -y nodejs
    RUN apt install -y npm && npm install redis
    RUN mkdir -p /opt/webapp
    EXPOSE 3000
    ENTRYPOINT ["node"]
    CMD ["/opt/webapp/app.js"]

    新的 app.js 中,也增加了对 Redis 的支持:

    sh
    const http = require('http');
    const url = require('url');
    const redis = require("redis");
    
    const hostname = '0.0.0.0';
    const port = 3000;
    
    const server = http.createServer((req, res) => {
      res.statusCode = 200;
      res.setHeader('Content-Type', 'text/plain');
      
      var api = req.url;
      
      // 解析 url 参数
      var params = url.parse(api, true).query;
      
      if (api.startsWith('/set')) {
      	console.log('/set');
        const client = redis.createClient('6379', 'db');
        client.on("error", function(error) {
          console.error(error);
        });
        
        client.set('params', JSON.stringify(params), (err, data) => {
    	 console.log('err: ', err, ' data: ', data);
    	 res.end(data);
    	});
    
      } else if (api.startsWith('/get')) {
      	console.log('/get');
        const client = redis.createClient('6379', 'db');
        client.on("error", function(error) {
          console.error(error);
        });
        
        client.get('params', (err, data) => {
    	 console.log('err: ', err, ' data: ', data);
    	 res.end(data);
    	});
      }
    });
    
    server.listen(port, hostname, () => {
      console.log(`Server running at http://${hostname}:${port}/`);
    });

    通过 redis 模块创建了一个到 Redis 的连接,用来连接主机名为 db 的 Redis 数据库,端口为 6379 。在请求 /set 时,将 URL 参数保存到了 Redis 数据库中,并在请求 /get 的时候从中取回这个值。

将 Express 应用程序连接到 Redis 容器

现在来更新 Express 应用程序,让其连接到 Redis 并存储传入的参数。

为此,需要能够与 Redis 服务器对话。也就是说,两个 Docker 容器之间怎么通信呢?要做到这一点,可以用以下几种方法。

  • Docker 的内部网络。
  • 从 Docker 1.9 及之后的版本开始,可以使用 Docker Networking 以及 docker network 命令。
  • Docker 链接。一个可以将具体容器链接到一起来进行通信的抽象层。

那么,应该选择哪种方法呢?

第一种方法,Docker 的内部网络这种解决方案并不是灵活、强大。虽然不推荐采用这种方式来连接 Docker 容器,但还是有必要了解 Docker 网络是如何工作的。

推荐使用 Docker Networking,因为:

  • Docker Networking 可以将容器连接到不同宿主机上的容器。
  • 通过 Docker Networking 连接的容器可以在无需更新连接的情况下,对停止、启动或者重启容器。而使用 Docker 链接,则可能需要更新一些配置,或者重启相应的容器来维护 Docker 容器之间的链接。
  • 使用 Docker Networking,不必事先创建容器再去连接它。同样,也不必关心容器的运行顺序,可以在网络内部获得容器名解析和发现。
Docker 内部连网

第一种方法涉及 Docker 自己的网络栈。到目前为止,我们看到的 Docker 容器都是公开端口并绑定到本地网络端口的,这样可以把容器里的服务在本地 Docker 宿主机所在的外部网络上(比如,把容器里的 80 端口绑定到本地宿主机的更高端口上)公开。除了这种用法,Docker 这个特性还有种用法我们没有见过,那就是内部网络。

在安装 Docker 时,会创建一个新的网络接口,名字是 docker0。每个 Docker 容器都会在这个接口上分配一个 IP 地址。来看看目前 Docker 宿主机上这个网络接口的信息:

sh
$ ip a show docker0
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:4a:e6:9b:8b brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:4aff:fee6:9b8b/64 scope link
       valid_lft forever preferred_lft forever

可以看到,docker0 接口有符合 RFC 1918 的私有 IP 地址,范围是 172.16~172.30。接口本身的地址 172.17.0.1 是这个 Docker 网络的网关地址,也是所有 Docker 容器的网关地址。

提示

Docker 会默认使用 172.17.x.x 作为子网地址,除非已经有别人占用了这个子网。如果这个子网被占用了,Docker 会在 172.16~172.30 这个范围内尝试创建子网。

接口 docker0 是一个虚拟的以太网桥,用于连接容器和本地宿主网络。如果进一步查看 Docker 宿主机的其他网络接口,会发现一系列名字以 veth 开头的接口。

sh
321: veth011c3f4@if320: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
    link/ether ce:22:bb:eb:6b:fe brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::cc22:bbff:feeb:6bfe/64 scope link
       valid_lft forever preferred_lft forever
325: veth0d97334@if324: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP group default
    link/ether ba:0c:39:39:47:72 brd ff:ff:ff:ff:ff:ff link-netnsid 2
    inet6 fe80::b80c:39ff:fe39:4772/64 scope link
       valid_lft forever preferred_lft forever

Docker 每创建一个容器就会创建一组互联的网络接口。这组接口就像管道的两端(就是说,从一端发送的数据会在另一端接收到)。这组接口其中一端作为容器里的 eth0 接口,而另一端统一命名为类似 veth011c3f4@if320 这种名字,作为宿主机的一个端口。可以把 veth 接口认为是虚拟网线的一端。这个虚拟网线一端插在名为 docker0 的网桥上,另一端插到容器里。通过把每个 veth* 接口绑定到 docker0 网桥,Docker 创建了一个虚拟子网,这个子网由宿主机和所有的 Docker 容器共享。

进入容器里面,看看这个子网管道的另一端:

sh
$ docker run -it ubuntu /bin/bash
root@f4a5b2ec693b:/# ip a show eth0
328: eth0@if329: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ac:11:00:04 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.0.4/16 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:acff:fe11:4/64 scope link
       valid_lft forever preferred_lft forever

可以看到,Docker 给容器分配了 IP 地址 172.17.0.4 作为宿主虚拟接口的另一端。这样就能够让宿主网络和容器互相通信了。

从容器内跟踪对外通信的路由,看看是如何建立连接的:

sh
root@f4a5b2ec693b:/# traceroute www.baidu.com
traceroute to www.baidu.com (104.193.88.123), 30 hops max, 60 byte packets
 1  172.17.0.1 (172.17.0.1)  0.035 ms  0.018 ms  0.014 ms
...(省略)...

可以看到,容器地址后的下一跳是宿主网络上 docker0 接口的网关 IP 172.17.0.1。

不过 Docker 网络还需要另一个配置才能允许建立连接:防火墙规则和 NAT 配置。这些配置允许 Docker 在宿主网络和容器间路由。现在来查看一下宿主机上的 IPTables NAT 配置:

sh
$ iptables -t nat -L -n
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination
DOCKER     all  --  0.0.0.0/0            0.0.0.0/0            ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT)
target     prot opt source               destination

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination
DOCKER     all  --  0.0.0.0/0           !127.0.0.0/8          ADDRTYPE match dst-type LOCAL

Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination
MASQUERADE  all  --  172.17.0.0/16        0.0.0.0/0
MASQUERADE  tcp  --  172.17.0.3           172.17.0.3           tcp dpt:6379
MASQUERADE  tcp  --  172.17.0.2           172.17.0.2           tcp dpt:3000

Chain DOCKER (2 references)
target     prot opt source               destination
RETURN     all  --  0.0.0.0/0            0.0.0.0/0
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:32771 to:172.17.0.3:6379
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:32773 to:172.17.0.2:3000

这里有几个值得注意的 IPTables 规则。首先,我们注意到,容器默认是无法访问的。从宿主网络与容器通信时,必须明确指定打开的端口。下面以 DNAT (即目标 NAT)这个规则为例,这个规则把容器里的访问路由到 Docker 宿主机的 32771 端口。

  • Redis 容器的网络

    下面用 docker inspect 命令来查看新的 Redis 容器的网络配置:

    sh
    $ docker inspect redis
    ...(省略)...
            "NetworkSettings": {
                "Bridge": "",
                "SandboxID": "fa63c5fd67d77e5b4a4fa2d0d3d2afdf186742ca887b0fe4dabcd7966494307b",
                "HairpinMode": false,
                "LinkLocalIPv6Address": "",
                "LinkLocalIPv6PrefixLen": 0,
                "Ports": {
                    "6379/tcp": [
                        {
                            "HostIp": "0.0.0.0",
                            "HostPort": "32771"
                        }
                    ]
                },
    ...(省略)...

    docker inspect 命令展示了 Docker 容器的细节,这些细节包括配置信息和网络状况。为了清晰,省略了大部分信息,只展示了网络配置。

    也可以在命令里使用 -f 选项,只获取 IP 地址:

    sh
    $ docker inspect -f '{{ .NetworkSettings.IPAddress }}' redis
    172.17.0.3

    通过运行 docker inspect 命令可以看到,容器的 IP 地址为 172.17.0.3,并使用了 docker0 接口作为网关地址。还可以看到 6379 端口被映射到本地宿主机的 32771 端口。因为 redis 容器运行在本地的 Docker 宿主机上,所以不仅可以用映射后的端口,也可以直接使用 172.17.0.3 地址 与 Redis 服务器的 6379 端口通信:

    sh
    $ redis-cli -h 172.17.0.3
    172.17.0.3:6379> QUIT

    在确认完可以连接到 Redis 服务之后,可以使用 quit 命令退出 Redis 接口。

因此,虽然第一眼看上去这是让容器互联的一个好方案,但可惜的是,这种方法有两个大问题:

  1. 要在应用程序里对 Redis 容器的 IP 地址做硬编码;
  2. 如果重启容器,Docker 可能会改变容器的 IP 地址。

现在用 docker restart 命令来看看地址的变化(如果使用 docker kill 命令杀死容器再重启,也会得到同样的结果):

sh
$ docker restart redis

查看一下容器的 IP 地址:

sh
$ docker inspect -f '{{ .NetworkSettings.IPAddress }}' redis
172.17.0.4

可以看到,Redis 容器有了新的 IP 地址 172.17.0.4,这就意味着,如果在 Express 应用程序里硬编码了原来的地址,那么现在就无法让应用程序连接到 Redis 数据库了。这可不那么好用。

下面来看一下,如何用新的连网框架连接容器。

Docker Networking

容器之间的连接用网络创建,这被称为 Docker Networking,也是 Docker 1.9 发布版本中的一个新特性。Docker Networking 允许用户创建自己的网络,容器可以通过这个网上互相通信。实质上,Docker Networking 以新的用户管理的网络补充了现有的 docker0。更重要的是,现在容器可以跨越不同的宿主机来通信,并且网络配置可以更灵活地定制。Docker Networking 也和 Docker Compose 以及 Swarm 进行了集成。

要想使用 Docker 网络,需要先创建一个网络,然后在这个网络下启动容器:

sh
$ docker network create app
505ff95d8e9e72da06dc93b0a83bf1d286a5fb2330d1babe08aacd2275eb5ef4

这里用 docker network 命令创建了一个桥接网络,命名为 app,这个命令返回新创建的网络的网络 ID。

然后可以用 docker network inspect 命令查看新创建的这个网络:

sh
$ docker network inspect app
[
    {
        "Name": "app",
        "Id": "505ff95d8e9e72da06dc93b0a83bf1d286a5fb2330d1babe08aacd2275eb5ef4",
        "Created": "2020-09-05T11:24:36.188425894+08:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.18.0.0/16",
                    "Gateway": "172.18.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {},
        "Options": {},
        "Labels": {}
    }
]

可以看到这个新网络是一个本地的桥接网络(这非常像 docker0 网络),而且现在还没有容器在这个网络中运行。

使用 docker network ls 命令列出当前系统中的所有网络:

sh
$  docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
505ff95d8e9e        app                 bridge              local
7af69d92b3b2        bridge              bridge              local
0054dde75c20        host                host                local
5f74032e4d80        none                null                local

先从启动 Redis 容器开始,在之前创建的 app 网络中添加一些容器:

sh
$ docker run -d -p 6379 --network app --name db my_ubuntu/redis

这里基于 my_ubuntu/redis 镜像创建了一个名为 db 的新容器。同时指定了一个新的选项 --network--network 选项指定了新容器将会在哪个网络中运行。

这时,如果再次运行 docker network inspect 命令,将会看到这个网络更详细的信息:

sh
$ docker network inspect app
[
    {
        "Name": "app",
        "Id": "505ff95d8e9e72da06dc93b0a83bf1d286a5fb2330d1babe08aacd2275eb5ef4",
        "Created": "2020-09-05T11:24:36.188425894+08:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.18.0.0/16",
                    "Gateway": "172.18.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "4b18f54b51547b510f30ca1f049a7525b49bdb900896d01e661384ecff4e5148": {
                "Name": "db",
                "EndpointID": "f228ccdf9ba850f99d1779e973952bbe0d8c6d4aa8d5abd633689ab24dc67353",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]

现在在这个网络中,可以看到一个容器,它有一个 MAC 地址,并且 IP 地址为 172.18.0.2。

接着,再在创建的网络下增加一个运行启用了 Redis 的 Express 应用程序的容器:

sh
$ docker run -d -p 3000 --network app --name webapp -v $PWD/webapp:/opt/webapp my_ubuntu/nodejs_express

再次运行 docker network inspect 命令查看 app 网络:

sh
$ docker network inspect app
[
    {
        "Name": "app",
        "Id": "505ff95d8e9e72da06dc93b0a83bf1d286a5fb2330d1babe08aacd2275eb5ef4",
        "Created": "2020-09-05T11:24:36.188425894+08:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "172.18.0.0/16",
                    "Gateway": "172.18.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "1a4154edd5423a6ff2e47b6b74369674ae96b8d27afa663f1c24feeda4d09564": {
                "Name": "webapp",
                "EndpointID": "20a0172f0d0dad41f95522ce71697cfc32f19e2dbb6f2434b1ddbbbe3254e627",
                "MacAddress": "02:42:ac:12:00:03",
                "IPv4Address": "172.18.0.3/16",
                "IPv6Address": ""
            },
            "4b18f54b51547b510f30ca1f049a7525b49bdb900896d01e661384ecff4e5148": {
                "Name": "db",
                "EndpointID": "f228ccdf9ba850f99d1779e973952bbe0d8c6d4aa8d5abd633689ab24dc67353",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]

在自定义的网络中,容器之间不仅可以通过 IP 地址进行通信,还可以将容器名称解析为 IP 地址。此功能称为自动服务发现(Automatic Service Discovery)

可以使用 docker exec 命令进入到 webapp 容器内,测试是否能连接到 db 容器:

sh
$ docker exec -it webapp /bin/bash
root@1a4154edd542:/# apt update -yqq && apt install -yqq iputils-ping
root@1a4154edd542:/# ping db
PING db (172.18.0.2) 56(84) bytes of data.
64 bytes from db.app (172.18.0.2): icmp_seq=1 ttl=64 time=0.078 ms
64 bytes from db.app (172.18.0.2): icmp_seq=2 ttl=64 time=0.051 ms
64 bytes from db.app (172.18.0.2): icmp_seq=3 ttl=64 time=0.049 ms
...(省略)...
root@1a4154edd542:/# exit
exit

下面就来检查一下 webapp 容器为这个应用程序绑定了哪个端口:

sh
$ docker port webapp
3000/tcp -> 0.0.0.0:32787

通过 curl 命令来测试一下我们的应用程序:

sh
$ curl localhost:32787/set?name=hello\&age=18
OK

再来确认一下 Redis 实例是否已经接收到了这次更新:

sh
$ curl localhost:32787/get
{"name":"hello","age":"18"}
通过 Docker 链接连接容器

在 Docker 1.9 之前,这是首选的容器连接方式,并且只有在运行 1.9 之前版本的情况下才推荐这种方式。

Docker 官网 https://docs.docker.com/network/links/ 是这样说的:

Warning: The flag is a legacy feature of Docker. It may eventually be removed. Unless you absolutely need to continue using it, we recommend that you use user-defined networks to facilitate communication between two containers instead of using . One feature that user-defined networks do not support that you can do with is sharing environment variables between containers. However, you can use other mechanisms such as volumes to share environment variables between containers in a more controlled way.

抱着学习的态度,来看看 Docker 链接是如何工作的。

从新建一个 Redis 容器开始:

sh
$ docker run -d --name redis my_ubuntu/redis

然后启动 Web 应用程序容器,并把它链接到新的 Redis 容器上去:

sh
docker run -it -p 3000 --entrypoint /bin/bash --name webapp --link redis:db -v $PWD/webapp:/opt/webapp my_ubuntu/nodejs_express

这次使用了一个新选项 --link--link 选项创建了两个容器间的客户-服务链接。这个选项需要两个参数:一个是要链接的容器的名字,另一个是链接的别名。上面的例子中,webapp 容器是客户,redis 容器是“服务”,并且为这个服务增加了 db 作为别名。这个别名让我们可以一致地访问容器公开的信息,而无须关注底层容器的名字。链接让服务容器有能力与客户容器通信,并且能分享一些连接细节,这些细节有助于在应用程序中配置并使用这个链接。

连接也能得到一些安全上的好处。注意,启动 Redis 容器时,并没有使用 -p 选项公开 Redis 的端口。因为不需要这么做。通过把容器链接在一起,可以让客户容器直接访问任意服务容器的公开端口(即客户 webapp 容器可以连接到服务 redis 容器的 6379 端口)。更妙的是,只有使用 --link 选项链接到这个容器的容器才能连接到这个端口。容器的端口不需要对本地宿主机公开,现在我们已经拥有一个非常安全的模型。通过这个安全模型,就可以限制容器化应用程序被攻击面,减少应用暴露的网络。

最后,让容器启动时加载 Shell,而不是服务守护进程,这样可以查看容器是如何链接在一起的。Docker 在容器里的以下两个地方写入了链接信息。

  • /etc/hosts 文件中。
  • 包含连接信息的环境变量中。

先来看看 /etc/hosts 文件:

sh
root@38ea0884241c:/# cat /etc/hosts
127.0.0.1       localhost
...(省略)...
172.17.0.2      db 414f8c132cde redis
172.17.0.3      38ea0884241c

可以看到 /etc/hosts 文件包含了 webapp 容器自己的 IP 地址和主机名(主机名是容器 ID 的一部分),以及一条 localhost 记录。还有一项是由该连接指令创建的,它是 redis 容器的 IP 地址、名字、容器 ID 和从该连接的别名衍生的主机名 db。现在试着 ping 一下 db 容器:

sh
$ docker exec -it webapp /bin/bash
root@38ea0884241c:/# apt update -yqq && apt install -yqq iputils-ping
root@38ea0884241c:/# ping db
PING db (172.17.0.2) 56(84) bytes of data.
64 bytes from db (172.17.0.2): icmp_seq=1 ttl=64 time=0.068 ms
64 bytes from db (172.17.0.2): icmp_seq=2 ttl=64 time=0.075 ms
64 bytes from db (172.17.0.2): icmp_seq=3 ttl=64 time=0.067 ms
...(省略)...
root@38ea0884241c:/# exit
exit

说明已经连到了 Redis 数据库,不过在利用这个连接之前,再来看看环境变量里包含的其他连接信息。

运行 env 命令来查看环境变量:

sh
root@38ea0884241c:/# env
DB_PORT=tcp://172.17.0.2:6379
DB_PORT_6379_TCP_ADDR=172.17.0.2
HOSTNAME=38ea0884241c
DB_PORT_6379_TCP=tcp://172.17.0.2:6379
DB_PORT_6379_TCP_PROTO=tcp
DB_PORT_6379_TCP_PORT=6379
DB_NAME=/webapp/db
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
_=/usr/bin/env
...(省略)...

可以看到不少环境变量,其中一些以 DB 开头。Docker 在连接 webapp 和 redis 容器时,自动创建了这些以 DB 开头的环境变量。以 DB 开头是因为 DB 是创建连接时使用的别名。

这些自动创建的环境变量包含以下信息:

  • 子容器的名字;
  • 容器里运行的服务所使用的协议、IP 和端口号;
  • 容器里运行的不同服务所指定的协议、IP 和端口号;
  • 容器里由 Docker 设置的环境变量的值。

具体的变量会因容器的配置不同而有所不同(如容器的 Dockerfil 中由 ENVEXPOSE 指令定义的内容)。重要的是,这些变量包含一些我们可以在应用程序中用来进行持久的容器间链接的信息。

现在就能测试容器连接是否能够正常工作了。

sh
$ docker run -d -p 3000 --name webapp --link redis:db -v $PWD/webapp:/opt/webapp my_ubuntu/nodejs_express
$ docker port webapp
3000/tcp -> 0.0.0.0:32792
$ curl localhost:32792/set?name=hello\&age=18
OK
$ curl localhost:32792/get
{"name":"hello","age":"18"}

使用 Docker 构建服务

构建第一个应用

要构建的第一个应用是使用 Vuepress 的自定义网站。

需要构建以下两个镜像:

  • 一个镜像安装了 Vuepress 及其他用于构建 Vuepress 网站的必要的软件包。
  • 一个镜像通过 Apache 来让 Vuepress 网站工作起来。

工作流程如下:

  1. 创建 Vuepress 基础镜像和 Apache 镜像(只需要构建一次)。
  2. 从 Vuepress 镜像创建一个容器,这个容器存放通过卷挂载的网站源代码。
  3. 从 Apache 镜像创建一个容器,这个容器利用包含编译后的网站的卷,并为其服务。
  4. 在网站需要更新时,清理并重复上面的步骤。

Vuepress 基础镜像

下面开始为第一个镜像(Vuepress 基础镜像)创建 Dockerfile。先创建一个新目录和一个空的 Dockerfile:

sh
$ mkdir vuepress
$ cd vuepress
$ touch Dockerfile

看看 Dockerfile 文件的内容:

dockerfile
FROM ubuntu:18.04
RUN apt update -yqq && apt install -yqq nodejs npm
RUN npm install -g npm
VOLUME /data
VOLUME /var/www/html
WORKDIR /data
ENTRYPOINT npm install && npm run build && /bin/cp -rf ./docs/.vuepress/dist/* /var/www/html/

这个 Dockerfile 基于 Ubuntu 18.04,并且安装了 Node.js 和用于支持 Vuepress 的包。然后使用 VOLUME 指令创建了以下两个卷。

  • /data/:用来存放网站的源代码。
  • /var/www/html/:用来存放构建后的 Vuepress 网站码。

接着将工作目录设置到 /data/,并通过 ENTRYPOINT 指令安装构建这个网站需要的 package 并指定自动构建的命令,这个命令会将工作目录 /data/ 中的所有的 Vuepress 网站代码构建到 /data/docs/.vuepress/dist/ 目录中,最后复制到 /var/www/html/ 目录下。

构建 Vuepress 基础镜像

通过这个 Dockerfile,可以使用 docker build 命令构建出可以启动容器的镜像:

sh
$ docker build -t my_ubuntu/vuepress .
Sending build context to Docker daemon 2.048 kB
Step 1/7 : FROM ubuntu:18.04
 ---> 2eb2d388e1a2
Step 2/7 : RUN apt update -yqq && apt install -yqq nodejs npm
...(省略)...
Step 7/7 : ENTRYPOINT npm install && npm run build && /bin/cp -rf ./docs/.vuepress/dist/* /var/www/html/
 ---> Running in 71fe0316aee5
 ---> 1b6331d7c22f
Removing intermediate container 71fe0316aee5
Successfully built 1b6331d7c22f

这样就构建了名为 my_ubuntu/vuepress、ID 为 1b6331d7c22f 的新镜像。这就是将要使用的新的 Vuepress 镜像。可以使用 docker images 命令来查看这个新镜像:

sh
$ docker images
REPOSITORY                     TAG                 IMAGE ID            CREATED             SIZE
my_ubuntu/vuepress             latest              1b6331d7c22f        2 minutes ago       453 MB
...(省略)...

Apache 镜像

接下来构建第二个镜像,一个用来启动新网站的 Apache 服务器。先创建一个新目录和一个空的 Dockerfile:

sh
$ mkdir apache
$ cd apache
$ touch Dockerfile

看看这个 Dockerfile 的内容:

dockerfile
FROM ubuntu:18.04
RUN apt update -yqq && apt install -yqq apache2
VOLUME /var/www/html
WORKDIR /var/www/html
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data
ENV APACHE_LOG_DIR /var/log/apache2
ENV APACHE_PID_FILE /var/run/apache2.pid
ENV APACHE_RUN_DIR /var/run/apache2
ENV APACHE_LOCK_DIR /var/lock/apache2
RUN mkdir -p $APACHE_RUN_DIR $APACHE_LOCK_DIR $APACHE_LOG_DIR
EXPOSE 80
ENTRYPOINT ["/usr/sbin/apache2"]
CMD ["-D", "FOREGROUND"]

这个镜像也是基于 Ubuntu 18.04,并安装了 Apache 服务器。然后使用 VOLUME 指令创建了一个卷,即 /var/www/html,用来存放编译后的 Vuepress 网站。然后将 /var/www/html 设为工作目录。

然后使用 ENV 指令设置了一些必要的环境变量,创建了必要的目录,并且使用 EXPOSE 指令公开了 80 端口。最后指定了 ENTRYPOINTCMD 指令组合来在容器启动时默认运行 Apache。

构建 Apache 镜像

有了这个 Dockerfile,可以使用 docker build 命令来构建可以启动容器的镜像:

sh
$ docker build -t my_ubuntu/apache .
Sending build context to Docker daemon 2.048 kB
Step 1/14 : FROM ubuntu:18.04
 ---> 2eb2d388e1a2
Step 2/14 : RUN apt update -yqq && apt install -yqq apache2
...(省略)...
Step 14/14 : CMD -D FOREGROUND
 ---> Running in b6bc02b420d0
 ---> 545fb13dab01
Removing intermediate container b6bc02b420d0
Successfully built 545fb13dab01

这样就构建了名为 my_ubuntu/apache、ID 为 545fb13dab01 的新镜像。这就是将要使用的 Apache 镜像。可以使用 docker images 命令来查看这个新镜像:

sh
$ docker images
REPOSITORY                     TAG                 IMAGE ID            CREATED             SIZE
my_ubuntu/apache               latest              545fb13dab01        2 minutes ago       190 MB
...(省略)...

启动 Vuepress 网站

现在有了以下两个镜像。

  • Vuepress:安装了 Node.js 及其他必备软件包的 Vuepress 镜像。
  • Apache:通过 Apache Web 服务器来让 Vuepress 网站工作起来的镜像。

在使用 docker run 命令来创建一个新的 Vuepress 容器开始我们的网站之前,先把网站源代码复制到 $HOME 目录:

sh
$ cd $HOME
$ git clone https://github.com/panxingcheng/vuepress_blog_demo.git

现在在 Vuepress 容器里使用这个示例数据:

sh
$ docker run -v $HOME/vuepress_blog_demo:/data --name vuepress_blog my_ubuntu/vuepress
...(省略)...
Thank you for using vuepress!
...(省略)...
 Server: Compiled successfully in 19.13s
 Client: Compiled successfully in 19.30s
wait Rendering static HTML...
success Generated static files in docs/.vuepress/dist.

上面启动了一个叫作 vuepress_blog 的新容器,把本地的 $HOME/vuepress_blog_demo/ 目录作为 /data/ 卷挂载到容器里。容器已经拿到网站的源代码,并将其构建到已编译的网站,存放到 /data/docs/.vuepress/dist/ 目录。

提示

卷是在一个或多个容器中特殊指定的目录,卷会绕过联合文件系统,为持久化数据和共享数据提供几个有用的特性。

  • 卷可以在容器间共享和重用。
  • 共享卷时不一定要运行相应的容器。
  • 对卷的修改会直接在卷上反映出来。
  • 更新镜像时不会包含对卷的修改。
  • 卷会一直存在,直到没有容器使用它们。

利用卷,可以在不用提交镜像修改的情况下,向镜像里加入数据(如源代码、数据或者其他内容),并且可以在容器间共享这些数据。

卷在 Docker 宿主机的 /var/lib/docker/volumes/ 目录中。可以通过 docker inspect 命令查看某个卷的具体位置:

sh
docker inspect -f '{{ range .Mounts }}{{.}}{{end}} <NAME|ID>

所以,如果想在另一个容器里使用 /data/docs/.vuepress/dist/ 卷里编译好的网站,可以创建一个新的链接到这个卷的容器:

sh
$ docker run -d -P --volumes-from vuepress_blog my_ubuntu/apache
f4f27bacd42ca77510ababe2bdbbfc6f09f3c87fcf288e4fcf4eb3379b17a539

这看上去和典型的 docker run 很像,只是使用了一个新选项 --volumes-from。选项 --volumes-from 把指定容器里的所有卷都加入新创建的容器里。这意味着,Apache 容器可以访问之前创建的 vuepress_blog 容器里 /var/www/html/ 卷中存放的编译后的 Vuepress 网站。即便 vuepress_blog 容器没有运行,Apache 容器也可以访问这个卷。想想,这只是卷的特性之一。不过,容器本身必须存在(即使删除了使用了卷的最后一个容器,卷中的数据也会持久保存。)。

构建 Vuepress 网站的最后一步是什么?来查看一下容器把已公开的 80 端口映射到了哪个端口:

sh
$ docker ps -lq
d0a132bb00f9
$ docker port d0a132bb00f9 80
0.0.0.0:32796

现在在 Docker 宿主机上浏览该网站:

(Vuepress 网站。图片来源:自己截得)

现在终于把 Vuepress 网站运行起来了!

更新 Vuepress 网站

如果要更新网站的数据,就更有意思了。通过编辑 vuepress_blog_demo/docs/README.md 文件来修改博客内容:

md
# Hello, World!
## This is My Blog!

那么如何才能更新博客网站呢?只需要再次使用 docker start 命令启动 Docker 容器即可:

sh
$ docker start vuepress_blog
vuepress_blog

看上去什么都没发生。我们来查看一下容器的日志:

sh
$ docker logs vuepress_blog
...(省略)...
 Server: Compiled successfully in 9.05s
 Client: Compiled successfully in 15.56s
wait Rendering static HTML...
success Generated static files in docs/.vuepress/dist.

可以看到,Vuepress 编译过程第二次被运行,并且网站已经被更新。这次更新已经写入了对应的卷。现在浏览 Vuepress 网站,就能看到变化了:

(更新后的 Vuepress 网站。图片来源:自己截得)

由于共享的卷会自动更新,这一切都不需要更新或者重启 Apache 容器。这个流程非常简单,可以将其扩展到更复杂的部署环境。

备份 Vuepress 卷

我们可能会担心万一不小心删除卷(尽管能使用已有的步骤轻松重建这个卷)。由于卷的优点之一就是可以挂载到任意容器,因此可以轻松备份它们。现在创建一个新容器,用来备份 /var/www/html 卷:

sh
$ docker run --rm --volumes-from vuepress_blog -v $PWD:/backup ubuntu tar -zcvf /backup/vuepress_blog_backup.tar.gz /var/www/html
tar: Removing leading `/' from member names
/var/www/html/
/var/www/html/assets/
/var/www/html/assets/js/
...(省略)...

这里运行了一个已有的 Ubuntu 容器,并把 vuepress_blog 的卷挂载到该容器里。这会在该容器里创建 /var/www/html 目录。然后使用 -v 选项把当前目录(通过 $PWD 命令获得)挂载到容器的 /backup 目录。最后容器运行这一备份命令。

提示

上面还指定了 --rm 选项,这个选项对于只用一次的容器,或者说用完即扔的容器,很有用。这个选项会在容器的进程运行完毕后,自动删除容器。对于只用一次的容器来说,这是一种很方便的清理方法。

扩展 Vuepress 网站

下面是几种扩展 Vuepress 网站的方法。

  • 运行多个 Apache 容器,这些容器都使用来自 vuepress_blog 容器的卷。在这些 Apache 容器前面加一个负载均衡器(比如 Nginx),我们就拥有了一个 Web 集群。

  • 进一步构建一个镜像,这个镜像把网站的源代码数据复制(如通过 git clone )到卷里。再把这个卷挂载到从 my_ubuntu/vuepress 镜像创建的容器。这就是一个可迁移的通用方案,而且不需要宿主机本地包含任何源代码。

使用 Docker 构建一个 Java 应用服务

现在来试一些稍微不同的方法,考虑把 Docker 作为应用服务器和编译管道。这次做一个更加“企业化”且用于传统工作负载的服务:获取 Tomcat 服务器上的 WAR 文件,并运行一个Java 应用程序。为了做到这一点,构建一个有两个步骤的 Docker 管道。

  • 一个镜像从 URL 拉取指定的 WAR 文件并将其保存到卷里。
  • 一个含有 Tomcat 服务器的镜像运行这些下载的 WAR 文件。

WAR 文件的获取程序

从构建一个镜像开始,这个镜像会下载 WAR 文件并将其挂载在卷里:

sh
$ mkdir fetcher
$ cd fetcher
$ touch Dockerfile

这个 Dockerfile 的内容:

dockerfile
FROM ubuntu:18.04
RUN apt update -yqq && apt install -yqq wget
VOLUME ["/var/lib/tomcat9/webapps/"]
WORKDIR /var/lib/tomcat9/webapps/
ENTRYPOINT ["wget"]
CMD [ "-?" ]

这个非常简单的镜像只做了一件事:容器执行时,使用 wget 从指定的 URL 获取文件并把文件保存在 /var/lib/tomcat9/webapps/ 目录。这个目录也是一个卷,并且是所有容器的工作目录。后面会把这个卷共享给 Tomcat 服务器并且运行里面的内容。

最后,如果没有指定 URL,ENTRYPOINTCMD 指令会在容器不带 URL 运行的时候通过返回 wget 帮助来做到这一点。

现在来构建这个镜像:

sh
$ docker build -t my_ubuntu/fetcher .

获取 WAR 文件

https://tomcat.apache.org/tomcat-9.0-doc/appdev/sample/下载 Apache Tomcat 示例应用:

sh
$ docker run -it --name sample my_ubuntu/fetcher https://tomcat.apache.org/tomcat-9.0-doc/appdev/sample/sample.war
--2020-09-25 03:33:39--  https://tomcat.apache.org/tomcat-9.0-doc/appdev/sample/sample.war
Resolving tomcat.apache.org (tomcat.apache.org)... 40.79.78.1, 95.216.24.32, 95.216.26.30, ...
Connecting to tomcat.apache.org (tomcat.apache.org)|40.79.78.1|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 4606 (4.5K)
Saving to: 'sample.war'

sample.war              100%[==============================>]   4.50K  20.8KB/s    in 0.2s

2020-09-25 03:33:44 (20.8 KB/s) - 'sample.war' saved [4606/4606]

可以看到,容器通过提供的 URL 下载了 sample.war 文件。从输出结果看不出最终的保存路径,但是因为设置了容器的工作目录,sample.war 文件最终会保存到 /var/lib/tomcat7/webapps/ 目录中。

可以在 /var/lib/docker 目录找到这个 WAR 文件。先用 docker inspect 命令查找卷的存储位置:

sh
$ docker inspect -f '{{ range .Mounts}}{{.}}{{end}}' sample
{volume 3d21e91a8ae6b020338bdc1aa8e7fb633dcce19b13cd0f58ed9466b1db95ac0b /var/lib/docker/volumes/3d21e91a8ae6b020338bdc1aa8e7fb633dcce19b13cd0f58ed9466b1db95ac0b/_data /var/lib/tomcat9/webapps local  true }

查看这个目录:

sh
$ ls -l /var/lib/docker/volumes/3d21e91a8ae6b020338bdc1aa8e7fb633dcce19b13cd0f58ed9466b1db95ac0b/_data
total 8
-rw-r--r-- 1 root root 4606 May  1  2018 sample.war

Tomecat 9 应用服务器

接下来构建 Tomcat 应用服务器的镜像来运行这个 WAR 文件:

sh
$ mkdir tomcat9
$ cd tomcat9
$ touch Dockerfile

看看这个 Dockerfile:

dockerfile
FROM ubuntu:18.04
RUN apt update -yqq && apt install -yqq tomcat9 default-jdk
ENV CATALINA_HOME /usr/share/tomcat9
ENV CATALINA_BASE /var/lib/tomcat9
ENV CATALINA_PID /var/run/tomcat9.pid
ENV CATALINA_SH /usr/share/tomcat9/bin/catalina.sh
ENV CATALINA_TMPDIR /tmp/tomcat9-tomcat9-tmp
RUN mkdir -p $CATALINA_TMPDIR
VOLUME ["/var/lib/tomcat9/webapps/"]
EXPOSE 8080
ENTRYPOINT ["/usr/share/tomcat9/bin/catalina.sh", "run"]

这个镜像很简单。首先需要安装 Java JDK 和 Tomcat 服务器。接着指定一些启动 Tomcat 需要的环境变量,然后创建一个临时目录,还创建了 /var/lib/tomcat9/webapps/ 卷,公开了 Tomcat 默认的 8080 端口,最后使用 ENTRYPOINT 指令来启动 Tomcat。

现在来构建 Tomcat 9 镜像:

sh
$ docker build -t my_ubuntu/tomcat9 .

运行 WAR 文件

创建一个新的 Tomcat 实例,运行示例应用:

sh
$ docker run -d -P --name sample_app --volumes-from sample my_ubuntu/tomcat9
5ab0c74970552378681517bb19a4b73a2311134c57c3d20e140c4e45ce334860

这会创建一个名为 sample_app 的容器,这个容器会复用 sample 容器里的卷。这意味着存储在 /var/lib/tomcat9/webapps/ 卷里的 WAR 文件会从 sample 容器挂载到 sample_app 容器,最终被 Tomcat 加载并执行。

为了在 Web 浏览器里看看这个示例程序,必须使用 docker port 命令找出被公开的端口:

sh
$ docker port sample_app 8080
0.0.0.0:32799

现在来浏览这个应用(使用 URL 和端口,并在最后加上 /sample)看看都有什么:

(Tomcat 示例应用。图片来源:自己截得)

多容器的应用栈

最后一个服务应用是把一个使用 Express 框架的、带有 Redis 后端的 Node.js 应用完全 Docker 化。

我们需要构建一系列的镜像来支持部署多容器的应用。

  • 一个 Node 容器,用来服务于 Node 应用。
  • 一个 Redis 主容器,用于保存和集群化应用状态。
  • 两个 Redis 副本容器,用于集群化应用状态。
  • 一个日志容器,用于捕获应用日志。

Node 应用程序会运行在一个容器中,它后面会有一个配置为“主-副本”模式运行在多个容器中的 Redis 集群。

Node.js 镜像

先从构建一个安装了 Node.js 的镜像开始,这个镜像有使用 Express 框架的应用和必要的软件包。

sh
$ mkdir nodejs
$ cd nodejs/
$ mkdir nodeapp
$ cd nodeapp/
$ touch package.json
$ touch server.js
$ cd ..
$ touch Dockerfile
  • package.json

    json
    {
      "name": "nodeapp",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "author": "",
      "license": "ISC",
      "dependencies": {
        "connect-redis": "^5.0.0",
        "cookie-parser": "^1.4.5",
        "express": "^4.17.1",
        "express-session": "^1.17.1",
        "morgan": "^1.10.0",
        "redis": "^3.0.2"
      }
    }
  • server.js

    js
    var fs = require('fs');
    var express = require('express'),
        session = require('express-session')
        cookieParser = require('cookie-parser')
        morgan = require('morgan')
        app = express(),
        redis = require('redis'),
        RedisStore = require('connect-redis')(session),
        server = require('http').createServer(app);
    
    var logFile = fs.createWriteStream('/var/log/nodeapp/nodeapp.log', {flags: 'a'});
    
    app.use(morgan('combined', {stream: logFile}));
    app.use(cookieParser('keyboard-cat'));
    app.use(session({
            resave: false,
            saveUninitialized: true,
            store: new RedisStore({
                client: redis.createClient(process.env.REDIS_PORT || 6379, 
                    process.env.REDIS_HOST || 'redis_primary')
            }),
            secret: 'keyboard cat',
            cookie: {
                expires: false,
                maxAge: 30 * 24 * 60 * 60 * 1000
            }
    }));
    
    app.get('/', function(req, res) {
      res.json({
        status: "ok"
      });
    });
    
    app.get('/hello/:name', function(req, res) {
      res.json({
    	hello: req.params.name
      });
    });
    
    var port = process.env.HTTP_PORT || 3000;
    server.listen(port);
    console.log('Listening on port ' + port);

    server.js 文件引入了所有的依赖,并集成了 Express 框架。Express 把会话(session)信息保存到 Redis 里,并创建了一个以 JSON 格式返回状态信息的节点。这个应用默认使用 redis_primary 作为主机名去连接 Redis,如果有必要,可以通过环境变量覆盖这个默认的主机名。

    这个应用会把日志记录到 /var/log/nodeapp/nodeapp.log 文件里,并监听 3000 端口。

上面创建了一个叫 nodejs 的新目录,然后创建了子目录 nodeapp 来保存 Node.js 应用的源代码。

最后回到了 nodejs 目录。现在来看看这个 Dockerfile 的内容:

dockerfile
FROM ubuntu:18.04
RUN apt update -yqq && apt install -yqq nodejs npm
RUN mkdir -p /var/log/nodeapp
ADD nodeapp /opt/nodeapp/
WORKDIR /opt/nodeapp
RUN npm install
VOLUME ["/var/log/nodeapp"]
EXPOSE 3000
ENTRYPOINT ["node", "server.js"]

这个镜像安装了 Node.js,然后将 nodeapp 的源代码通过 ADD 指令添加到 /opt/nodeapp/ 目录。

接着将工作目录设置为 /opt/nodeapp/,并且安装了 Node.js 应用的必要软件包,还创建了用于存放 Node.js 应用日志的卷 /var/log/nodeapp

最后公开了 3000 端口,并使用 ENTRYPOINT 指令指定了运行 Node.js 应用的命令 node server.js

现在来构建镜像:

sh
$ docker build -t my_ubuntu/nodejs .

Redis 基础镜像

现在继续构建第一个 Redis 镜像:安装 Redis 的基础镜像。然后会使用这个镜像构建 Redis 主镜像和副本镜像。

sh
$ mkdir redis_base
$ cd redis_base/
$ touch Dockerfile

创建 Redis 基础镜像的 Dockerfile:

dockerfile
FROM ubuntu:18.04
RUN apt update -yqq && apt install -yqq software-properties-common
RUN add-apt-repository ppa:chris-lea/redis-server
RUN apt install -yqq redis-server redis-tools
VOLUME ["/var/lib/redis", "/var/log/redis/"]
EXPOSE 6379

这个 Redis 基础镜像安装了最新版本的 Redis(从 PPA 库安装,而不是使用 Ubuntu 自带的较老的 Redis 包),指定了两个 VOLUME/var/lib/redis/var/log/redis),公开了 Redis 的默认端口 6379。因为不会执行这个镜像,所以没有包含 ENTRYPOINT 或者 CMD 指令。下面将基于这个镜像构建别的镜像。

构建 Redis 基础镜像:

sh
$ docker build -t my_ubuntu/redis .

Redis 主镜像

继续构建第一个 Redis 镜像,即 Redis 主服务器:

sh
$ mkdir redis_primary
$ cd redis_primary/
$ touch Dockerfile

创建 Redis 主镜像的 Dockerfile:

dockerfile
FROM my_ubuntu/redis
ENTRYPOINT ["redis-server", "--protected-mode no", "--logfile /var/log/redis/redis-server.log", "--loglevel verbose"]

Redis 主镜像基于之前的 my_ubuntu/redis 镜像,并通过 ENTRYPOINT 指令指定了 Redis 服务启动命令,Redis 服务的日志文件保存到 /var/log/redis/redis-server.log

构建 Redis 主镜像:

sh
$ docker build -t my_ubuntu/redis_primary .

Redis 副本镜像

为了配合 Redis 主镜像,需要创建 Redis 副本镜像,保证为 Node.js 应用提供 Redis 服务的冗余度:

sh
$ mkdir redis_replica
$ cd redis_replica/
$ touch Dockerfile

创建 Redis 副本镜像的 Dockerfile:

dockerfile
FROM my_ubuntu/redis
ENTRYPOINT ["redis-server", "--logfile /var/log/redis/redis-server.log", "--slaveof redis_primary 6379"]

Redis 副本镜像也是基于 my_ubuntu/redis 构建的,并且通过 ENTRYPOINT 指令指定了运行 Redis 服务器的命令,设置了日志文件和 slaveof 选项。这就把 Redis 配置为“主-副本”模式,从这个镜像构建的任何容器都会将 redis_primary 主机的 Redis 作为主服务,连接其 6379 端口,成为其对应的副本服务器。

构建 Redis 副本镜像:

sh
$ docker build -t my_ubuntu/redis_replica .

创建 Redis 后端集群

现在已经有了 Redis 主镜像和副本镜像,已经可以构建 Redis 复制环境了。首先创建一个用来连接应用程序的网络,称其为 express:

sh
$ docker network create express
df678953a8cb29d1a7b90fa33d2fb6758cffe2f2035058824af591bffdadfd8a

现在在这个网络中运行 Redis 主容器:

sh
$ docker run -d -h redis_primary --network express --name redis_primary my_ubuntu/redis_primary
9dea572cf5b7246680fd967100f12f0371a3568ef646f64c5bb44d5af886143c

这里使用 docker run 命令从 my_ubuntu/redis_primary 镜像创建了一个容器。

  • -h:用来设置容器的主机名。这会覆盖默认的行为(默认将容器的主机名设置为容器 ID)并允许我们指定自己的主机名。

使用这个选项可以确保容器使用 redis_primary 作为主机名,并被本地的 DNS 服务正确解析。

接着指定了 --name 选项,确保容器的名字是 redis_primary,还指定了 --network 选项,确保该容器在 express 网络中运行。稍后将使用这个网络来保证容器连通性。

使用 docker logs 命令来查看 Redis 主容器的运行状况:

sh
$ docker logs redis_primary

什么日志都没有?这是怎么回事?原来 Redis 服务会将日志记录到一个文件而不是记录到标准输出,所以使用 Docker 查看不到任何日志。那怎么能知道 Redis 服务器的运行情况呢?为了做到这一点,可以使用之前创建的 /var/log/redis 卷。

现在来看看这个卷,读取一些日志文件的内容:

sh
$ docker run -it --rm --volumes-from redis_primary ubuntu cat /var/log/redis/redis-server.log
1:C 28 Sep 2020 07:22:19.363 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 28 Sep 2020 07:22:19.363 # Redis version=6.0.6, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 28 Sep 2020 07:22:19.363 # Configuration loaded
1:M 28 Sep 2020 07:22:19.365 * Running mode=standalone, port=6379.
1:M 28 Sep 2020 07:22:19.365 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.
1:M 28 Sep 2020 07:22:19.365 # Server initialized
1:M 28 Sep 2020 07:22:19.365 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
1:M 28 Sep 2020 07:22:19.365 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled.
1:M 28 Sep 2020 07:22:19.365 * Ready to accept connections

这里以交互方式运行了另一个容器。这个命令指定了 --rm 选项,它会在进程运行完后自动删除容器。还指定了 --volumes-from 选项,告诉它从 redis_primary 容器挂载了所有的卷。然后指定了一个 ubuntu 基础 镜像,并告诉它执行 cat /var/log/redis/redis-server.log 来展示日志文件。这种方法利用了卷的优点,可以直接从 redis_primary 容器挂载 /var/log/redis 目录并读取里面的日志文件。

查看 Redis 日志,可以看到一些常规警告,不过一切看上去都没什么问题。Redis 服务器已经准备好从 6379 端口接收数据了。

下一步,创建一个 Redis 副本容器:

sh
$ docker run -d -h redis_replica1 --network express --name redis_replica1 my_ubuntu/redis_replica
a15c9074ef07ab13b6ce6e5943ad1178fb22b16a281c4075bc3ed90482904119

这里运行了另一个容器:这个容器来自 my_ubuntu/redis_replica 镜像。和之前一样,命令里指定了主机名(通过 -h 选项)和容器名(通过 --name 选项)。还使用了 --network 选项在 express 网络中运行 Redis 副本容器。

检查一下这个新容器的日志:

sh
$ docker run -it --rm --volumes-from redis_replica1 ubuntu cat /var/log/redis/redis-server.log
...(省略)...
1:S 28 Sep 2020 07:24:01.378 * Ready to accept connections
1:S 28 Sep 2020 07:24:01.378 * Connecting to MASTER redis_primary:6379
1:S 28 Sep 2020 07:24:01.380 * MASTER <-> REPLICA sync started
1:S 28 Sep 2020 07:24:01.380 * Non blocking connect for SYNC fired the event.
1:S 28 Sep 2020 07:24:01.380 * Master replied to PING, replication can continue...
1:S 28 Sep 2020 07:24:01.380 * Partial resynchronization not possible (no cached master)
1:S 28 Sep 2020 07:24:01.381 * Full resync from master: b6da35883520d15d155d408ec27b34df215d4ec7:0
1:S 28 Sep 2020 07:24:01.475 * MASTER <-> REPLICA sync: receiving 175 bytes from master to disk
1:S 28 Sep 2020 07:24:01.475 * MASTER <-> REPLICA sync: Flushing old data
1:S 28 Sep 2020 07:24:01.475 * MASTER <-> REPLICA sync: Loading DB in memory
1:S 28 Sep 2020 07:24:01.475 * Loading RDB produced by version 6.0.6
1:S 28 Sep 2020 07:24:01.475 * RDB age 0 seconds
1:S 28 Sep 2020 07:24:01.475 * RDB memory usage when created 1.82 Mb
1:S 28 Sep 2020 07:24:01.475 * MASTER <-> REPLICA sync: Finished with success

到这里已经成功启动了 redis_primary 和 redis_replica1 容器,并让这两个容器进行主从复制。

现在来加入另一个副本容器 redis_replica2,确保万无一失:

sh
$ docker run -d -h redis_replica2 --network express --name redis_replica2 my_ubuntu/redis_replica
40b9f8993b80d75cc801935c8e47d20b96ba8002bdecf60e3516cee50e751627
$ docker run -it --rm --volumes-from redis_replica2 ubuntu cat /var/log/redis/redis-server.log
...(省略)...
1:S 28 Sep 2020 07:25:20.125 * Ready to accept connections
1:S 28 Sep 2020 07:25:20.125 * Connecting to MASTER redis_primary:6379
1:S 28 Sep 2020 07:25:20.127 * MASTER <-> REPLICA sync started
1:S 28 Sep 2020 07:25:20.127 * Non blocking connect for SYNC fired the event.
1:S 28 Sep 2020 07:25:20.128 * Master replied to PING, replication can continue...
1:S 28 Sep 2020 07:25:20.128 * Partial resynchronization not possible (no cached master)
1:S 28 Sep 2020 07:25:20.129 * Full resync from master: b6da35883520d15d155d408ec27b34df215d4ec7:112
1:S 28 Sep 2020 07:25:20.204 * MASTER <-> REPLICA sync: receiving 175 bytes from master to disk
1:S 28 Sep 2020 07:25:20.204 * MASTER <-> REPLICA sync: Flushing old data
1:S 28 Sep 2020 07:25:20.204 * MASTER <-> REPLICA sync: Loading DB in memory
1:S 28 Sep 2020 07:25:20.204 * Loading RDB produced by version 6.0.6
1:S 28 Sep 2020 07:25:20.204 * RDB age 0 seconds
1:S 28 Sep 2020 07:25:20.204 * RDB memory usage when created 1.85 Mb
1:S 28 Sep 2020 07:25:20.205 * MASTER <-> REPLICA sync: Finished with success

现在可以确保 Redis 服务万无一失了!

创建 Node.js 容器

为 Node.js 应用启动一个容器:

sh
$ docker run -d -p 3000:3000 --network express --name nodeapp my_ubuntu/nodejs
e96e59d9ac0b5b07517c15379730f014d1821b841a82534490490399eeb3f344

上面从 my_ubuntu/nodejs 镜像创建了一个新容器,命名为 nodeapp,并将容器内的 3000 端口映射到宿主机的 3000 端口。同样新的 nodeapp 容器也是运行在 express 网络中。

使用 docker logs 命令来看看 nodeapp 容器在做什么:

sh
$ docker logs nodeapp
Listening on port 3000

现在在 Docker 宿主机上打开相应的网页,看看应用工作的样子:

(Node.js 应用程序。图片来源:自己截得)

这个输出表明应用正在工作。浏览器的会话状态会先被记录到 Redis 主容器 redis_primary ,然后复制到两个 Redis 副本容器 redis_replica1 和 redis_replica2。

捕获应用日志

通常在生产环境里需要确保可以捕获日志并将日志保存到日志服务器。下面将使用 Logstash 来完成这件事。

先来创建一个 Logstash 镜像:

sh
$ mkdir logstash
$ cd logstash/
$ touch Dockerfile

创建 Logstash 的 Dockerfile:

dockerfile
FROM ubuntu:18.04
RUN apt update -yqq && apt install -yqq wget gnupg2 openjdk-8-jdk
RUN wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | apt-key add -
RUN echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | tee -a /etc/apt/sources.list.d/elastic-7.x.list
RUN apt update -yqq && apt install -yqq logstash
ADD logstash.conf /usr/share/logstash/
WORKDIR /usr/share/logstash
ENTRYPOINT ["bin/logstash"]
CMD ["-f", "logstash.conf", "--config.reload.automatic"]

上面创建了镜像并安装了 Logstash,然后将 logstash.conf 文件使用 ADD 指令添加到 /usr/share/logstash/ 目录。

接着指定了工作目录为 /usr/share/logstash。最后指定了 ENTRYPOINTbin/logstash,并且指定了 CMD-f logstash.conf --config.reload.automatic。这样容器启动时会启动 Logstash 并加载 /usr/share/logstash/logstash.conf 配置文件。

现在来看看 logstash.conf 文件的内容:

sh
input {
  file {
    type => "syslog"
    path => ["/var/log/nodeapp/nodeapp.log", "/var/log/redis/redis-server.log"]
  }
}
output {
  stdout {
    codec => rubydebug
  }
}

这个 Logstash 配置很简单,它监控两个文件,即 /var/log/nodeapp/nodeapp.log/var/log/redis/redis-server.log。Logstash 会一直监视这两个文件,将其中新的内容发送给 Logstash。配置文件的第二部分是 output 部分,接受所有 Logstash 输入的内容并将其输出到标准输出上(现实中,一般会将 Logstash 配置为输出到 Elasticsearch 集群或者其他的目的地)。

构建 Logstash 镜像:

sh
$ docker build -t my_ubuntu/logstash .

构建好镜像后,可以从这个镜像启动一个容器:

sh
$ docker run -d --volumes-from nodeapp --volumes-from redis_primary --name logstash my_ubuntu/logstash

上面成功地启动了一个名为 logstash 的新容器,并指定了两次 --volumes-from 选项,分别挂载了 redis_primary 和 nodeapp 容器的卷,这样就可以访问 Redis 和 Node.js 的日志文件了。任何追加到这些日志文件里的内容都会反映在 logstash 容器的卷里,并传给 Logstash 做后续处理。

现在来查看 logstash 容器的日志:

sh
$ docker logs -f logstash
...(省略)...
[INFO ] 2020-09-28 07:28:45.230 [[main]-pipeline-manager] javapipeline - Starting pipeline {:pipeline_id=>"main", "pipeline.workers"=>1, "pipeline.batch.size"=>125, "pipeline.batch.delay"=>50, "pipeline.max_inflight"=>125, "pipeline.sources"=>["/usr/share/logstash/logstash.conf"], :thread=>"#<Thread:0x2f097212 run>"}
[INFO ] 2020-09-28 07:28:47.350 [[main]-pipeline-manager] javapipeline - Pipeline Java execution initialization time {"seconds"=>2.11}
[INFO ] 2020-09-28 07:28:47.892 [[main]-pipeline-manager] file - No sincedb_path set, generating one based on the "path" setting {:sincedb_path=>"/usr/share/logstash/data/plugins/inputs/file/.sincedb_909ed736d7c365cf58504be3464ee132", :path=>["/var/log/nodeapp/nodeapp.log", "/var/log/redis/redis-server.log"]}
[INFO ] 2020-09-28 07:28:47.978 [[main]-pipeline-manager] javapipeline - Pipeline started {"pipeline.id"=>"main"}
[INFO ] 2020-09-28 07:28:48.138 [Agent thread] agent - Pipelines running {:count=>1, :running_pipelines=>[:main], :non_running_pipelines=>[]}
[INFO ] 2020-09-28 07:28:48.298 [[main]<file] observingtail - START, creating Discoverer, Watch with file and sincedb collections
[INFO ] 2020-09-28 07:28:49.074 [Api Webserver] agent - Successfully started Logstash API endpoint {:port=>9600}

现在再在浏览器里刷新 Web 应用,产生一个新的日志事件。这样应该能在 logstash 容器的输出中看到这个事件:

sh
{
    "@timestamp" => 2020-09-28T07:30:32.055Z,
          "path" => "/var/log/nodeapp/nodeapp.log",
       "message" => "::ffff:183.4.42.91 - - [28/Sep/2020:07:30:31 +0000] \"GET / HTTP/1.1\" 304 - \"-\" \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.42 Safari/537.36 Edg/86.0.622.19\"",
          "host" => "bc3c84f99f4d",
          "type" => "syslog",
      "@version" => "1"
}
{
    "@timestamp" => 2020-09-28T07:30:32.056Z,
          "path" => "/var/log/redis/redis-server.log",
       "message" => "1:M 28 Sep 2020 07:30:31.551 - DB 0: 1 keys (1 volatile) in 4 slots HT.",
          "host" => "bc3c84f99f4d",
          "type" => "syslog",
      "@version" => "1"
}

现在 Node.js 和 Redis 容器都将日志输出到了 Logstash。在生产环境中,这些事件会发到 Logstash 服务器并存储在 Elasticsearch 里。如果要加入新的 Redis 副本容器或者其他组件,也可以很容易地将其日志输出到日志容器里。

Docker 编排和服务发现

编排(orchestration)是一个没有严格定义的概念。这个概念大概描述了自动配置、协作和管理服务的过程。在 Docker 的世界里,编排用来描述一组实践过程,这个过程会管理运行在多个 Docker 容器里的应用,而这些 Docker 容器有可能运行在多个宿主机上。

Docker Compose

现在先来熟悉一下 Docker Compose。Docker Compose 是用于定义和运行多容器 Docker 应用程序的工具。通过 Docker Compose,可以使用 YML 文件来配置应用程序需要的所有服务。然后,使用一个命令,就可以从 YML 文件配置中创建并启动所有服务。

Docker Compose 使用的三个步骤:

  • 使用 Dockerfile 定义应用程序的环境。

  • 使用 docker-compose.yml 文件定义构成应用程序的服务,这样它们可以在隔离环境中一起运行。

  • 最后,执行 docker-compose up 命令来启动并运行整个应用程序。

安装 Docker Compose

在 Linux 上安装 Docker Compose

在 Linux 上,可以从 GitHub 上下载 Docker Compose 的可执行包。

  1. 运行此命令以下载 Docker Compose 的当前稳定版本:

    sh
    $ curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
  2. 如果是非 root 用户,可能需要对二进制文件应用可执行权限:

    sh
    $ sudo chmod +x /usr/local/bin/docker-compose
  3. 通过使用 --version 选项调用 docker-compose 命令来测试其可以正常工作:

    sh
    $ docker-compose -version
    docker-compose version 1.27.4, build 40524192

获取示例应用

为了演示 Docker Compose 是如何工作的,这里使用一个简单 Python Web 应用程序作为例子。应用程序使用 Flask 框架,并在 Redis 中维护命中计数器。这个例子使用了以下两个容器。

  • 应用容器,运行 Python 示例程序。
  • Redis 容器,运行 Redis 数据库。

首先,创建一个目录并创建 Dockerfile:

sh
$ mkdir composeapp
$ cd composeapp/
$ touch Dockerfile

之后,需要添加应用程序的源代码。创建一个名叫 app.py 的文件,Python 代码内容如下:

py
from flask import Flask
from redis import Redis
import os
app = Flask(__name__)
redis = Redis(host="redis_1", port=6379)
@app.route('/')
def hello():
	redis.incr('hits')
	return 'Hello Docker! I have been seen {0} times'.format(redis.get('hits'))
if __name__ == "__main__":
	app.run(host="0.0.0.0", debug=True)

这个简单的 Flask 应用程序追踪保存在 Redis 里的计数器。每次访问根路径 / 时,计数器会自增。

现在还需要创建 requirements.txt 文件来保存应用程序的依赖关系,文件内容如下:

sh
flask
redis

现在来看看 Dockerfile:

dockerfile
# Docker Compose 示例应用的镜像
FROM python:2.7
ADD . /composeapp
WORKDIR /composeapp
RUN pip install -r requirements.txt

这个 Dockerfile 很简单。它基于 python:2.7 镜像构建。首先添加文件 app.pyrequirements.txt 到镜像中的 /composeapp 目录。之后 Dockerfile 将工作目录设置为 /composeapp,并执行 pip 命令来安装应用的依赖:flask 和 redis。

使用 docker build 来构建镜像:

sh
$ docker build -t my_python/composeapp .

这样就创建了一个名叫 my_python/composeapp 的容器,这个容器包含了示例应用和应用需要的依赖。现在可以使用 Docker Compose 来部署应用了。

docker-compose.yml 文件

在 Docker Compose 中,不仅可以定义一组要启动的服务(以 Docker 容器的形式表现),还可以定义希望这些服务要启动的运行时属性,这些属性和 docker run 命令需要的参数类似。将所有与服务有关的属性都定义在一个 YAML 文件里。之后执行 docker-compose up 命令,Docker Compose 会启动这些容器,使用指定的参数来执行,并将所有的日志输出合并到一起。

先来为这个应用创建 docker-compose.yml 文件:

sh
$ touch docker-compose.yml

docker-compose.ymlYAML 格式的文件,包括了一个或者多个运行 Docker 容器的指令。现在来看看示例应用使用的指令:

yml
web:
 image: my_python/composeapp
 command: python app.py
 ports:
  - "5000:5000"
 volumes:
  - .:/composeapp
 links:
  - redis
redis:
 image: redis

每个要启动的服务都使用一个 YAML 的散列键定义:web 和 redis。

对于 web 服务,指定了一些运行时参数。首先,使用 image 指定了要使用的镜像:my_python/composeapp 镜像。

提示

Docker Compose 也可以构建 Docker 镜像。可以使用 build 指令,并提供一个到 Dockerfile 的路径,让 Docker Compose 构建一个镜像,并使用这个镜像创建服务:

yml
web:
 build: /home/composeapp
...(省略)...

这个 build 指令会使用 /home/composeapp 目录下的 Dockerfile 来构建 Docker 镜像。

接着使用 command 指定服务启动时要执行的命令。接下来使用 portsvolumes 指定了服务要映射到的端口和卷,这里让服务里的 5000 端口映射到主机的 5000 端口,并创建了卷 /composeapp。最后使用 links 指定了要连接到服务的其他服务:将 redis 服务连接到 web 服务。

同样作用的 docker run 命令:

sh
$ docker run -d -p 5000:5000 -v .:/composeapp --link redis:redis --name web my_python/composeapp python app.py

最后指定了另一个名叫 redis 的服务。这个服务没有指定任何运行时的参数,一切使用默认的配置。

提示

可以在 Docker Compose 官网查看 docker-compose.yml 所有可用的指令列表。

运行 Docker Compose

一旦在 docker-compose.yml 中指定了需要的服务,就可以使用 docker-compose up 命令来执行这些服务:

sh
$ cd composeapp/
$ docker-compose up
Creating composeapp_redis_1 ... done
Creating composeapp_web_1   ... done
Attaching to composeapp_redis_1, composeapp_web_1
redis_1  | 1:C 15 Oct 2020 13:41:21.957 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
redis_1  | 1:C 15 Oct 2020 13:41:21.957 # Redis version=6.0.8, bits=64, commit=00000000, modified=0, pid=1, just started
redis_1  | 1:C 15 Oct 2020 13:41:21.957 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
redis_1  | 1:M 15 Oct 2020 13:41:21.959 * Running mode=standalone, port=6379.
redis_1  | 1:M 15 Oct 2020 13:41:21.959 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.
redis_1  | 1:M 15 Oct 2020 13:41:21.959 # Server initialized
redis_1  | 1:M 15 Oct 2020 13:41:21.959 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
redis_1  | 1:M 15 Oct 2020 13:41:21.959 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo madvise > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled (set to 'madvise' or 'never').
redis_1  | 1:M 15 Oct 2020 13:41:21.959 * Ready to accept connections
web_1    |  * Serving Flask app "app" (lazy loading)
web_1    |  * Environment: production
web_1    |    WARNING: This is a development server. Do not use it in a production deployment.
web_1    |    Use a production WSGI server instead.
web_1    |  * Debug mode: on
web_1    |  * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
web_1    |  * Restarting with stat

可以看到 Docker Compose 创建了 composeapp_redis_1 和 composeapp_web_1 这两个新的服务。那么,这两个名字是从哪儿来的呢?为了保证服务是唯一的,Docker Compose 将 docker-compose.yml 文件中指定的服务名字加上了目录名作为前缀,并分别使用数字作为后缀。

之后 Docker Compose 接管了每个服务输出的日志,输出的日志每一行都使用缩短的服务名字作为前缀,并交替输出在一起。

服务(和 Docker Compose)交替运行。这意味着,如果使用 Ctrl+C 来停止 Docker Compose 运行,也会停止运行的服务。也可以在运行 Docker Compose 时指定 -d 选项,以守护进程的模式来运行服务(类似于 docker run -d 选项):

sh
$ docker-compose up -d
Starting composeapp_redis_1 ... done
Starting composeapp_web_1   ... done

来看看现在宿主机上运行的示例应用。这个应用绑定在宿主机所有网络接口的 5000 端口上,所以可以使用宿主机的 IP 或者通过 localhost 来浏览该网站。

sh
$ curl localhost:5000
Hello Docker! I have been seen 1 times
$ curl localhost:5000
Hello Docker! I have been seen 2 times

可以看到这个页面上显示了当前计数器的值。刷新网站,会看到这个值在增加。每次刷新都会增加保存在 Redis 里的值。Redis 更新是通过由 Docker Compose 控制的 Docker 容器之间的链接实现的。

使用 Docker Compose

现在来看看 Docker Compose 的其他选项。

使用 docker-compose ps 命令(docker ps 命令的近亲)可以查看这些服务的运行状态。docker-compose ps 命令列出了本地 docker-compose.yml 文件里定义的正在运行的所有服务:

sh
$ cd composeapp/
$ docker-compose ps
	Name                     Command               State           Ports
------------------------------------------------------------------------------------
composeapp_redis_1   docker-entrypoint.sh redis ...   Up      6379/tcp
composeapp_web_1     python app.py                    Up      0.0.0.0:5000->5000/tcp

这个命令展示了正在运行的 Docker Compose 服务的一些基本信息:每个服务的名字、启动服务的命令以及每个服务映射到的端口。

还可以使用 docker-compose logs 命令来进一步查看服务的日志事件:

sh
$ docker-compose logs -f
Attaching to composeapp_web_1, composeapp_redis_1
redis_1  | 1:C 15 Oct 2020 13:41:21.957 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
...(省略)...
web_1    |  * Serving Flask app "app" (lazy loading)
web_1    |  * Environment: production
web_1    |    WARNING: This is a development server. Do not use it in a production deployment.
web_1    |    Use a production WSGI server instead.
web_1    |  * Debug mode: on
web_1    |  * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
web_1    |  * Restarting with stat
...(省略)...

这个命令会追踪服务的日志文件,很类似 tail -f 命令。与 tail -f 命令一样,想要退出可以使用 Ctrl+C

使用 docker-compose stop 命令可以停止正在运行的服务:

sh
$ docker-compose stop
Stopping composeapp_web_1   ... done
Stopping composeapp_redis_1 ... done

这个命令会同时停止两个服务。如果该服务没有停止,可以使用 docker-compose kill 命令强制杀死该服务。

现在可以用 docker-compose ps 命令来验证服务确实停止了:

sh
$ docker-compose ps
       Name                     Command               State    Ports
--------------------------------------------------------------------
composeapp_redis_1   docker-entrypoint.sh redis ...   Exit 0
composeapp_web_1     python app.py                    Exit 0

如果使用 docker-compose stop 或者 docker-compose kill 命令停止服务,还可以使用 docker-compose start 命令重新启动这些服务。这与使用 docker start 命令重启服务很类似:

sh
$ docker-compose start
Starting redis ... done
Starting web   ... done

最后,可以使用 docker-compose rm 命令来删除这些服务:

sh
$ docker-compose kill
Killing composeapp_web_1   ... done
Killing composeapp_redis_1 ... done
$ docker-compose rm
Going to remove composeapp_web_1, composeapp_redis_1
Are you sure? [yN] y
Removing composeapp_web_1   ... done
Removing composeapp_redis_1 ... done

首先会提示你确认需要删除服务,确认之后两个服务都会被删除。docker-compose ps 命令现在会显示没有运行中或者已经停止的服务:

sh
$ docker-compose ps
Name   Command   State   Ports
------------------------------

Released under the MIT License.