前言

最近学习了 Docker,以此文作为总结,并希望帮助更多的人入门 Docker。万事开头难,文中可能存在一些描述不合理的地方,但是有些知识不太容易用语言和文字去讲述,但我还是尽最大的努力去通俗的讲解,另一方面,学习新东西,多动手是有很大好处的。

一、如何通俗的理解 Docker?

如何通俗的理解 Docker?

在现在日常的开发中,项目能否正常运行与项目的运行环境有很大的联系。例如:开发测试环境所用的服务器是 CentOS,但是生产环境是 Ubuntu,你在开发环境测试的好好的,但是一部署生产就出问题。只要使用Docker,就能解决这个问题。

Docker能将项目的运行环境进行“克隆”,假如开发时,项目的运行环境为 CentOS、JDK8。Docker 能将项目所依赖的这些外部环境统统“打包”为一个整体结构,之后即使你将这个包放在生产环境 Ubuntu 中,或者任意的其他环境(只要也安装了 Docker),Docker 能迅速“解压”这个整体结构重新得到这个完整的运行环境(CentOS + JDK8),你得项目仍然可以在这个运行环境中运行,就不会出现如上问题。

听起来有点类似于“好像是在 Ubuntu 上新建了一个虚拟机,然后安装了CentOS和JDK一样”但是虚拟机提供的是虚拟化的硬件资源,而在虚拟机上安装的 CentOS 和 JDK 是实实在在的软件。而 Docker 提供的是虚拟化的运行环境,是操作系统层面的虚拟化。简单来说,虚拟机是硬件的虚拟,Docker 是操作系统的虚拟。

除此之外,Docker 还能进行隔离。在“解压”还原完整的运行环境的时候,Docker 会将它控制在一定的空间内,于是你这个应用只能在这个空间内运行,外部的其他空间不受影响。就好比日常的解压缩到一个指定的文件夹中一样,你不管内容怎么变都在这个文件夹内。

将外部所有的环境打成的这个包就叫做Docker镜像,只要两边都安装有 Docker 引擎就能完成打包和解压。Docker 引擎“解压”镜像重新还原整个环境,并存储在一个有限的空间内。这个包含运行环境的空间就是Docker 容器(容器为你的应用提供了 CentOS 和 JDK 的环境,将你的应用扔到进这个容器再运行就能得到和开发环境相同的运行效果),容器具有隔离性,外部的资源不受影响。镜像是静态的,容器是动态的。

从 Docker 的 Logo 看,Docker 的原理来源于集装箱,集装箱之所以极大的提高了运输效率,关键在于两点:标准、封装、隔离。每个集装箱都是由运行环境+应用构成,形成相对独立的单元,每个集装箱之间独立而互不影响。

下图参考知乎如何通俗解释Docker是什么?
为什么 Docker 又小又快?

容器环境并不一定是完整的 Linux 操作系统,只是集成了所必须的依赖,而很多对于应用程序而言用不到的(如图形化界面等),Docker 并不会将这些也打进 Docker 镜像,这样在体积方面也占有很大优势,Docker 镜像通常是比较小的,因此,Docker 的启动非常快。从实现原理上来说,容器就是加了隔离机制的进程而已,所以它自然速度很快。

例如,Docker 镜像通常不会包含 6 个不同的 Shell 让读者选择——通常 Docker 镜像中只有一个精简的Shell,甚至没有 Shell。镜像中还不包含内核——容器都是共享所在 Docker 主机的内核。

Docker 的几点优势

  • Docker 的镜像提供了除内核外完整的运行时环境,确保了应用运行环境一致性,从而不会再出现 “这段代码在我机器上没问题啊” 这类问题。
  • 可以很轻易的将在一个平台上运行的应用,迁移到另一个平台上,而不用担心运行环境的变化导致应用无法正常运行的情况。
  • 可以做到秒级、甚至毫秒级的启动,大大的节约了开发、测试、部署的时间。
  • 避免公用的服务器,资源会容易受到其他用户的影响。
  • 善于处理集中爆发的服务器使用压力

二、安装Docker

接下来以 CentOS 8 为例安装 Docker,对于 CentOS 而言,至少要 CentOS 7 及以上版本才行。其他系统参考官方文档Docker官方文档。这部分会涉及到一些名词,暂时不用考虑,以下章节会介绍。

  1. 配置yum源为阿里:

    1.1 备份原有的 yum 源:

    $ mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.back
    

    1.2 下载阿里的 yum 源:

    $ wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-8.repo
    

    1.3 清除 yum 缓存:

    $ yum clean all
    

    1.4 生成新的缓存:

    $ yum makecache
    
  2. 安装依赖,在安装依赖前,最好执行 yum update,如果之前安装失败过,请先执行yum -y remove docker docker-common docker-selinux docker-engine

    $ sudo yum install -y yum-utils \device-mapper-persistent-data \lvm2
    
  3. 设置标准库,或者使用http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo阿里云的

    $ sudo yum-config-manager \--add-repo \https://download.docker.com/linux/centos/docker-ce.repo
    
  4. 安装 Docker 引擎,执行命令发现报错,因为依赖 containerd.io 版本过低

    $ sudo yum install docker-ce docker-ce-cli containerd.io
    

  5. 升级 containerd.io 版本,升级完毕后再次执行上一部的代码,安装完成之后如图所示:

    $ wget https://download.docker.com/linux/centos/7/x86_64/edge/Packages/containerd.io-1.2.6-3.3.el7.x86_64.rpm$ yum install -y  containerd.io-1.2.6-3.3.el7.x86_64.rpm
    

  6. 启动 Docker,然后查看 Docker 已安装的版本:

    $ service docker restart # 或者systemctl start docker
    $ docker -v
    Docker version 19.03.7, build 7141c199a2
    
  7. 配置 Docker 镜像源:

    $ cd /etc/docker
    $ touch daemon.json
    

    加入如下内容:

    {"registry-mirrors": ["https://jzngeu7d.mirror.aliyuncs.com"]
    }
    

    然后重启 Docker:

    service docker restart
    
  8. 通过 hello-world 镜像验证 docker 引擎已正确安装:

    $ docker run hello-world
    

  9. 添加用户"aspire"到 Docker 组:

    $ sudo usermod -aG docker aspire
    

三、Docker 镜像

什么是 Docker 镜像

Docker 镜像(Docker Image)可以看做是一系列只读的文件,正如上文所说的那样,Docker 引擎通过这些文件“恢复”为一个完整的运行时环境。Docker 镜像包含了容器启动所需的所有信息,包括运行程序和配置数据。下图是Docker官方文档的解释:

翻译 + 个人理解: 从根本上来说,容器其实就是一个运行着的进程,但是由于要将这个进程和主机以及其他容器隔离开,给这个运行着的进程增加了一些功能的封装。之所以容器具备隔离性最重要的原因之一是因为容器与它自己对应的文件系统相互作用,这个文件系统就是 Docker 镜像提供的。Docker 镜像包含应用运行所需要的一切资源,包括 code、二进制文件、运行环境和一些依赖等。

Docker 镜像仓库

Docker 镜像仓库类似于 Maven 仓库,使用者可以从仓库中取现成的镜像直接使用,也可以将自己构建的镜像发布到仓库中供其他人下载使用。

在Docker镜像注册服务(Image Registry)中,存放着多个仓库。每个仓库集中存放某一类镜像,往往包括多个镜像文件,通过不同的标签(tag)来进行区分。例如存放Ubuntu操作系统镜像的仓库,称为 Ubuntu 仓库,其中包括14.04,12.04等不同版本的镜像,虽然都是 Ubuntu 镜像,但是是由不同版本的系统构建的,因此是不同的镜像文件。类似于 maven 的 artifactId + version。

Docker 仓库存放镜像的格式为:库名:标签(repository:tag)。需要注意的是,标签 tag 是人为指定的,一个镜像可以对应多个标签,但是一个标签只能找到一个镜像。例如,我可以将一个 Ubuntu 镜像打成多个标签:Ubuntu:v1 和 Ubuntu:14.04。

Docker 镜像注册服务是可以配置的,正如 maven 可以修改使用阿里仓库一样,其中 Docker Hub 为官方仓库,是默认配置。

正如 maven 仓库一样,Docker 仓库也有公有和私有之分。
Docker 镜像常用命令

拉取镜像 docker image pull

Docker安装之后,本地并没有镜像,使用 docker image pull <repository>:<tag>命令从远程仓库中下载镜像,注意,在上一章节中,使用命令 docker run hello-world 运行 hello-world 镜像之前,docker 会从中央仓库先下载到本地然后运行。

docker image pull alpine:latest 命令会从 alpine 仓库中拉取标签为 latest 的镜像。如果没有在仓库名称后指定具体的镜像标签,则 Docker 会假设用户希望拉取标签为 latest 的镜像。标签为 latest 的镜像没有什么特殊魔力!标有 latest 标签的镜像不保证这是仓库中最新的镜像!例如,Alpine 仓库中最新的镜像通常标签是 edge。通常来讲,使用 latest 标签时需要谨慎!

Alpine 操作系统是一个面向安全的轻型 Linux 发行版,它是由非商业组织维护的。Alpine 关注安全,性能和资源效能。Alpine 镜像可以适用于更多常用场景,并且是一个优秀的可以适用于生产的基础系统/环境。Alpine Docker 镜像相比于其他 Docker 镜像,它的容量非常小,仅仅只有 5 MB 左右(对比 Ubuntu 系列镜像接近 200 MB),且拥有非常友好的包管理机制。官方镜像来自 docker-alpine 项目。 —— Docker从入门到实践

目前 Docker 官方已开始推荐使用 Alpine 替代之前的 Ubuntu 做为基础镜像环境。这样会带来多个好处。包括镜像下载速度加快,镜像安全性提高,主机之间的切换更方便,占用更少磁盘空间等。

本地镜像仓库通常位于 /var/lib/docker/ 下,里面有 image 文件夹,进入后有一个二级目录 overlay2 ,进入这个目录后,里面有 repositories.json 文件,这个 json 文件记录了 image 的信息,可以看到下载的 hello-world 镜像标签为 latest 。

{"Repositories": {"hello-world": {"hello-world:latest": "sha256:fce289e99eb9bca977dae136fbe2a82b6b7d4c372474c9235adc1741675f587e","hello-world@sha256:fc6a51919cfeb2e6763f62b6d9e8815acbf7cd2e476ea353743570610737b752": "sha256:fce289e99eb9bca977dae136fbe2a82b6b7d4c372474c9235adc1741675f587e"}}
}

以下是docker image pull alpine:latest 命令运行实例结果,还可以用 docker image pull -a 命令拉取仓库中的所有镜像。

[root@localhost apps]# docker image pull alpine:latestlatest: Pulling from library/alpine
c9b1b535fdd9: Pull complete
Digest: sha256:ab00606a42621fb68f2ed6ad3c88be54397f981a7b70a79db3d1172b11c4367d
Status: Downloaded newer image for alpine:latest
docker.io/library/alpine:latest

查看镜像

可以通过 docker image ls 查看本地仓库中包含的镜像,还可以简写为 docker images ,正如上文中提到的那样,一个镜像可以有多个标签。因此可能存在 tag 不同而 image id 完全相同的镜像在列表中存在。

[root@localhost apps]# docker image lsREPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB
hello-world         latest              fce289e99eb9        14 months ago       1.84kB

可以在这个命令中添加 -q 参数来只返回所有镜像的 id :

[aspire@localhost ~]$ docker image ls -q
e7d92cdc71fe
fce289e99eb9

Docker 提供 --filter 参数来过滤 docker image ls 命令返回的镜像列表内容,还可以简写为 -f

查看悬虚(dangling)镜像,所谓悬虚镜像指的是没有 tag 标签的镜像,通常出现这种情况,是因为构建了一个新镜像,然后为这个新镜像打了一个已经存在的标签。例如:有一个 hello-world:v1 镜像,现在又构建了一个镜像 hello-world,然后为这个镜像打上了 v1 标签, Docker 会移除旧镜像上面的标签,将该标签标在新的镜像之上,这样原来的镜像就失去了标签,成为了悬虚镜像。悬虚镜像在列表中展示为 <none>:<none> 。一般来说,悬虚镜像是没什么用的,使用命令 docker image prune 删除所有的悬虚镜像。如下实例查看悬虚镜像:

[root@localhost apps]# docker image ls --filter dangling=true
[root@localhost apps]# docker image ls -f dangling=true
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
<none>              <none>              4fd34165afe0        7 days ago          14.5MB

查看在指定镜像之前被创建的全部镜像,使用 before 过滤器,这个过滤器接收一个镜像 id 。如下所示,这个镜像 id 参数在能唯一标识的情况下并不需要写全。

[aspire@localhost ~]$ docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB
hello-world         latest              fce289e99eb9        14 months ago       1.84kB[aspire@localhost ~]$ docker image ls --filter before=e7
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world         latest              fce289e99eb9        14 months ago       1.84kB

查看在指定镜像之后被创建的全部镜像,使用 since 过滤器,这个过滤器同 before 一样,接收一个镜像 id 为参数,如下所示:

[aspire@localhost apps]$ docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB
hello-world         latest              fce289e99eb9        14 months ago       1.84kB[aspire@localhost apps]$ docker image ls --filter since=fc
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB

使用 reference 过滤器进行模糊匹配,例如查看以 “hello” 开头的镜像,查看标签为 latest 的镜像:

[aspire@localhost apps]$ docker image ls --filter reference="hello*"
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world         latest              fce289e99eb9        14 months ago       1.84kB[aspire@localhost apps]$ docker image ls --filter reference="*latest"
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB
hello-world         latest              fce289e99eb9        14 months ago       1.84kB

Docker 提供 --format 参数来对 docker image ls 命令返回的镜像内容进行格式化。如下所示,只返回镜像的大小:

[aspire@localhost apps]$ docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB
hello-world         latest              fce289e99eb9        14 months ago       1.84kB[aspire@localhost apps]$ docker image ls --format "{{.Size}}"
5.59MB
1.84kB

需要注意的是,Go 模板里的属性(Docker 基于 Go 语言)和原始 docker image ls 的属性不太一样,例如上例中的 .Size ,更多的例如查看镜像 ID 为 {{.ID}} ,查看标签为 {{.Tag}} ,如下示例,以等距显示镜像 ID,镜像仓库名称以及标签:

[aspire@localhost apps]$ docker image ls --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}"
IMAGE ID            REPOSITORY          TAG
e7d92cdc71fe        alpine              latest
fce289e99eb9        hello-world         latest

搜索镜像

使用命令 docker search 来搜索镜像,这个命令需要 NAME 参数,例如搜索 alpine 镜像,如下示例只给出了部分搜索结果,默认情况下,Docker 只返回 25 行结果,可以通过 --limit 参数来增加返回内容行数,最多为 100 行。列表中既有官方的也有非官方的,参见 OFFICIAL 属性。读者可以使用 --filter "is-official=true",使命令返回内容只显示官方镜像。

[aspire@localhost ~]$ docker search alpine
NAME                                   DESCRIPTION                                     STARS               OFFICIAL            AUTOMATED
alpine                                 A minimal Docker image based on Alpine Linux…   6207                [OK]                [aspire@localhost ~]$ docker search alpine --limit 2
NAME                  DESCRIPTION                                     STARS               OFFICIAL            AUTOMATED
alpine                A minimal Docker image based on Alpine Linux…   6207                [OK]
anapsix/alpine-java   Oracle Java 8 (and 7) with GLIBC 2.28 over A…   439                                     [OK]

查看镜像详细信息

使用命令 docker inspect 查看镜像详细信息。在这里先介绍几点,一个是镜像的 Id ,docker inspect 命令可以看到完整的镜像 Id ,可以看到是一个 64 位十六进制字符串,为简化使用,前 12 个字符可以组成一个短ID,可以在命令行中使用。短ID还是有一定的碰撞机率。

第二点是镜像的摘要,RepoDigests 中的内容。镜像的摘要可以确保镜像在拉取的时候没有发生改变,这个值基于其内容的密码散列值,也就是说,一旦镜像内容发生改变,这个摘要值必然发生改变。可以通过命令 docker image ls --digests 查看镜像的摘要,对于一个镜像而言,摘要值也是唯一的,因此可以通过 docker image pull alpine@sha256:ab00606a42621fb68f2ed6ad3c88be54397f981a7b70a79db3d1172b11c4367d 这种方式拉取镜像。

[aspire@localhost ~]$ docker inspect alpine
[{"Id": "sha256:e7d92cdc71feacf90708cb59182d0df1b911f8ae022d29e8e95d75ca6a99776a","RepoTags": ["alpine:latest"],"RepoDigests": ["alpine@sha256:ab00606a42621fb68f2ed6ad3c88be54397f981a7b70a79db3d1172b11c4367d"],"Parent": "","Comment": "","Created": "2020-01-18T01:19:37.187497623Z","Container": "3e0860fab68af5a0364d739e6c851c1a1c70fc49e7c9f4f974fb79b27a19e651","ContainerConfig": {"Hostname": "3e0860fab68a","Domainname": "","User": "","AttachStdin": false,"AttachStdout": false,"AttachStderr": false,"Tty": false,"OpenStdin": false,"StdinOnce": false,"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd": ["/bin/sh","-c","#(nop) ","CMD [\"/bin/sh\"]"],"ArgsEscaped": true,"Image": "sha256:c80f3a794aba97667bd449f9cc59b2c72d593a8a3ca170b65ab4e57c4055e29f","Volumes": null,"WorkingDir": "","Entrypoint": null,"OnBuild": null,"Labels": {}},"DockerVersion": "18.06.1-ce","Author": "","Config": {"Hostname": "","Domainname": "","User": "","AttachStdin": false,"AttachStdout": false,"AttachStderr": false,"Tty": false,"OpenStdin": false,"StdinOnce": false,"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd": ["/bin/sh"],"ArgsEscaped": true,"Image": "sha256:c80f3a794aba97667bd449f9cc59b2c72d593a8a3ca170b65ab4e57c4055e29f","Volumes": null,"WorkingDir": "","Entrypoint": null,"OnBuild": null,"Labels": null},"Architecture": "amd64","Os": "linux","Size": 5591300,"VirtualSize": 5591300,"GraphDriver": {"Data": {"MergedDir": "/var/lib/docker/overlay2/9fd37d51d679d29a7997e19891728a695c11cd74a8a3df4784bd336e7db59a74/merged","UpperDir": "/var/lib/docker/overlay2/9fd37d51d679d29a7997e19891728a695c11cd74a8a3df4784bd336e7db59a74/diff","WorkDir": "/var/lib/docker/overlay2/9fd37d51d679d29a7997e19891728a695c11cd74a8a3df4784bd336e7db59a74/work"},"Name": "overlay2"},"RootFS": {"Type": "layers","Layers": ["sha256:5216338b40a7b96416b8b9858974bbe4acc3096ee60acbc4dfb1ee02aecceb10"]},"Metadata": {"LastTagTime": "0001-01-01T00:00:00Z"}}
]

另一个重要的信息则是 Layers 中的内容,这里显示了镜像每层的详细信息,可以看到 alpine 这个镜像只有一层。在外部看来,Docker 镜像是一个整体文件,但是其实是由多个只读层构成的,每一个镜像都可能依赖于由一个或多个下层的组成的另一个镜像。我们有时说,下层那个镜像是上层镜像的父镜像。例如,在第一章中提到的一个包含 CentOS 和 JDK 的镜像,对于这个镜像而言,必然由多层构成,其一是 CentOS 镜像,然后在这个镜像的基础上搭建了 JDK,最终返回一个包含这两层的一个镜像,于是在外部看来,好像是一个独立的,一个整体的镜像。因此,镜像只是一个虚拟的概念,其实际体现并非由一个文件组成,而是由一组文件系统组成,或者说,由多层文件系统联合组成。

在镜像拉取(docker pull)的过程中,也是分层拉取的,可能上面的 alpine 的例子不足以说明,可以参考 第六章:定制镜像 中 docker pull nginx 的输出内容,每一次拉取完之后都会输出 Pull complete ,如果其中一层已经存在,那么将不再去仓库中拉取,直接复用本地。

如下图所示,左边是多个只读层,它们重叠在一起,每一层都有一个指针指向下一层(父镜像)。最后一层没有父镜像,因此这一层也叫基础镜像,就像上面所示的 alpine 镜像一样。所有的 Docker 镜像都起始于一个基础镜像层,每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。同时,这样的设计也使得镜像层能够复用,例如在 CentOS 中安装一个 Python,由于构建 JDK 时并没有对 CentOS 这层镜像修改,因此这层基础镜像完全可以被复用于构建 Python 的过程。这是 Docker 镜像内部的实现细节。统一文件系统(union file system)技术能够将不同的层整合成一个文件系统,为这些层提供了一个统一的视角,这样就隐藏了多层的存在,在用户的角度看来,只存在一个文件系统,就像图片的右边所展示的那样。

在命令 docker image ls 中添加 -a 参数,查看主机中所有的镜像层。 docker image ls 命令只展示出了所有镜像的顶层。这两个命令的区别如下图所示:

docker images:

docker images -a:

如果想要查看某一个镜像下的所有层(其实也就是该镜像的构建历史记录),可以使用 docker history 命令来查看,该命令接收一个镜像 ID 。

[aspire@localhost ~]$ docker image ls -a
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB
hello-world         latest              fce289e99eb9        14 months ago       1.84kB[aspire@localhost ~]$ docker history e7d
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
e7d92cdc71fe        7 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>           7 weeks ago         /bin/sh -c #(nop) ADD file:e69d441d729412d24…   5.59MB

删除镜像

使用命令 docker image rm 删除镜像,如果要删除的镜像被其他镜像所依赖,那么只有当全部依赖该镜像层的镜像都被删除后,该镜像层才会被删除。另一方面,如果镜像存在运行着的容器时,就不能删除这个镜像。这个好比一个文件已经被打开,此时将无法删除这个文件一样。

[aspire@localhost ~]$ docker image rm e7
Untagged: alpine:latest
Untagged: alpine@sha256:ab00606a42621fb68f2ed6ad3c88be54397f981a7b70a79db3d1172b11c4367d
Deleted: sha256:e7d92cdc71feacf90708cb59182d0df1b911f8ae022d29e8e95d75ca6a99776a
Deleted: sha256:5216338b40a7b96416b8b9858974bbe4acc3096ee60acbc4dfb1ee02aecceb10

删除主机上所有的镜像,可以将 docker image ls -q 返回的结果传递给 docker image rm 命令:

[aspire@localhost ~]$ docker image rm $(docker image ls -q) -f

四、Docker 容器

什么是 Docker 容器?

在第一章节中,已经提及到 Docker 容器的意思,其实“Docker 将这些外部文件解压得到的运行环境称为容器”这样的说法只是便于初步理解,并不是很严谨。总之,镜像只是静态文件,容器通过镜像提供应用的运行环境,容器也能保证应用的隔离。 通过一个镜像可以创建多个容器,就像一个压缩文件可以多处解压一样。

容器分为两个状态,一个是没有运行的容器,一个是运行着的容器。对于没有运行的容器而言,其底层结构和镜像很相似,只不过是在镜像的顶层加了一层可写层,我们可以称这个 “为了容器运行时读写而准备的存储层” 为容器存储层,因此,通过镜像创建容器,其实只是在镜像的上层构建容器存储层的过程,容器一旦被删除,容器存储层就会消失。静态容器 = 镜像 + 容器存储层。到此为止,静态容器仍然是一个文件系统,由若干文件组成。

接下来要讨论运行着的容器,需要注意的是,容器要运行,则必须包含一个进程。容器的生命周期与其内部的这个应用程序进程的生命周期相同,程序运行完毕,则容器运行结束。正如第二章运行的 hello-world 容器一样,输出完毕,容器就停止运行了。容器运行停止了,就回到了上个状态,进程虽然运行结束,但是容器存储层还在,因此,如果进程新建了一个 .txt 文件并写入了一些内容,容器是可以保存下来的。

运行态的容器 = 静态容器 + 隔离的进程,运行的容器为里面的进程提供了隔离的进程空间。因此,容器内的进程是运行在一个隔离的环境里的,使用起来,就好像是在一个独立于宿主的系统下操作一样。这也是容器和虚拟机的相似点。总之,运行态的容器,提供了一个与外界隔离的集装箱,这个集装箱里有自己的文件系统,还有一个进程在此基础上运行。

到现在就可以讨论一个问题了,容器和虚拟机的区别。对于虚拟机而言,首先虚拟机虚拟出若干硬件资源,然后在这台虚拟的机器上装上操作系统和一些软件依赖,最后才能运行应用。就像下图所示。

而对于容器而言,无论以怎样的方式,只要在操作系统(这个操作系统也可以是通过虚拟机安装的 CentOS)上安装了 Docker 引擎,就可以运行 Docker 容器。因此,可以说,虚拟机将硬件物理资源划分为虚拟资源,而容器将系统资源划分为虚拟资源。另一方面,操作系统本身是有其额外开销的。例如,每个操作系统都消耗一点 CPU、一点 RAM、一点存储空间等,搭建四个虚拟机,四个操作系统就有四份 OS 开销。而容器共享系统内核,因此在一台主机上运行数十个甚至数百个容器都是可能的,并且只有一份 OS 开销。

其实 Docker 容器技术并不新鲜,Linux 操作系统本身就支持虚拟化技术,叫做Linux container,也就是大家到处能看到的LXC的全称。虚拟化容器依赖于三个技术:CGroups,namespace 和 unionFS。
CGroups 全称control group,用来限定一个进程的资源使用,由Linux 内核支持,可以限制和隔离Linux进程组 (process groups) 所使用的物理资源 ,比如 CPU,内存,磁盘和网络IO等。CGroups 是 Linux container 技术的物理基础。
namespace 用来隔离PID(进程ID),IPC,Network 等系统资源。相比之下,CGroups 隔离的是物理资源。

Docker 容器常用命令

查看容器

使用命令 docker container ls 命令查看当前正在运行的容器, 给这个命令添加 -a 参数查看主机中所有的容器,可以看到在第一章中启动过的以 hello-world 为镜像的容器,在这个列表中,Command 为容器进程,如上文所述,容器必须包含一个进程。容器的名字 NAMES 在最后一列。

[aspire@localhost ~]$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                      PORTS               NAMES
fc1efa9f4077        hello-world         "/hello"                 42 hours ago        Exited (0) 42 hours ago                         blissful_pare

启动容器

启动一个 Docker 容器分为两步,如上文所述,第一步是创建容器,也就是创建容器存储层。第二部是启动容器,启动容器的时候会自动创建进程隔离空间。使用命令 docker create 创建容器,该命令接收镜像的名称并将返回容器的长 ID ,容器名称会自动生成,也可以添加 --name 参数来给定容器名称。

[aspire@localhost ~]$ docker create hello-world
0be430db6f970e35efb38e462f0d31a5a2464db8c7ea81ecbeabd1e2f2ecb296[aspire@localhost ~]$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
0be430db6f97        hello-world         "/hello"            7 seconds ago       Created                                 busy_swanson

有了容器,然后使用命令 docker container start 或者 docker start 启动容器,该命令接收容器的 ID 或者容器的 Name 。

[aspire@localhost ~]$ docker start busy_swanson
busy_swanson

使用命令 docker start 启动容器时,并不会输出容器执行的内容,可以给该命令添加 -a 参数,打开输出流,这也就能持续得到容器的输出。还可以使用 docker logs 命令来获取容器的输出信息:

[aspire@localhost ~]$ docker start -a busy_swansonHello from Docker!
This message shows that your installation appears to be working correctly.To generate this message, Docker took the following steps:1. The Docker client contacted the Docker daemon....[aspire@localhost ~]$ docker logs busy_swanson Hello from Docker!
This message shows that your installation appears to be working correctly.To generate this message, Docker took the following steps:1. The Docker client contacted the Docker daemon....

Docker 提供了更为简单的命令来完成这两步操作,使用命令 docker container run 或者是 docker run 来创建并启动容器,也就是说 docker run 其实是 docker create 和 docker start 的结合,并且该命令能将容器的输出信息输出到控制台中。但是需要注意的是,docker run 总是创建根据镜像一个新的容器,可以看到执行完后有两个通过镜像 hello-world 创建的容器。

[aspire@localhost ~]$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
0be430db6f97        hello-world         "/hello"            6 minutes ago       Exited (0) 5 minutes ago                       busy_swanson[aspire@localhost ~]$ docker run hello-worldHello from Docker!
This message shows that your installation appears to be working correctly.To generate this message, Docker took the following steps:1. The Docker client contacted the Docker daemon.2. The Docker daemon pulled the "hello-world" image from the Docker Hub.(amd64)3. The Docker daemon created a new container from that image which runs theexecutable that produces the output you are currently reading.4. The Docker daemon streamed that output to the Docker client, which sent itto your terminal.To try something more ambitious, you can run an Ubuntu container with:$ docker run -it ubuntu bashShare images, automate workflows, and more with a free Docker ID:https://hub.docker.com/For more examples and ideas, visit:https://docs.docker.com/get-started/[aspire@localhost ~]$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
2b98bedbe466        hello-world         "/hello"            7 seconds ago       Exited (0) 6 seconds ago                       tender_gould
0be430db6f97        hello-world         "/hello"            6 minutes ago       Exited (0) 5 minutes ago                       busy_swanson[aspire@localhost ~]$

如下实例以 alpine 镜像创建容器,容器内必须包含一个进程,由结果可以看到,对于 alpine 而言,默认的进程是 /bin/sh ,也就是说容器启动后,alpine 的命令行模式就已经开启,通过使用 alpine 里的 sh ,就可以像操作一个新的系统一样,安装一些其他的软件。

[aspire@localhost ~]$ docker create alpine
646ef88984ee0ce71866f6dff59e982bd98c1a456eb55581f25019b741f0fe4a[aspire@localhost ~]$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
646ef88984ee        alpine              "/bin/sh"           6 seconds ago       Created                                 happy_knuth

使用命令 docker run 创建并运行容器,并添加参数 -i -t 连接到容器内的终端上。-i 表示 interactive 交互式,让容器的标准输入保持打开。-t 表示得到一个 terminal(让Docker分配一个伪终端(pseudo-tty)并绑定到容器的标准输入上)。以下实例通过交互式终端输出容器内部列表,并以命令 docker start -ai 重新启动该容器。

[aspire@localhost apps]$ docker run -it alpine
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var
/ # exit[aspire@localhost apps]$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                         PORTS               NAMES
eb2170fb3e8e        alpine              "/bin/sh"           25 seconds ago      Exited (0) 19 seconds ago                          adoring_volhard
b99b9016b774        hello-world         "/hello"            About an hour ago   Exited (0) About an hour ago                       tender_jones[aspire@localhost apps]$ docker start -ai adoring_volhard
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var
/ # exit[aspire@localhost apps]$

其实,docker run 的标准格式为 docker container run <options> <image>:<tag> <app> ,显示的指定运行在其中的应用,如下所示,只不过 /bin/sh 作为默认的应用运行在容器中而已,默认的应用对于不同的系统有所不同,例如 Ubuntu 中是 /bin/bash。

[aspire@localhost apps]$ docker run -it alpine /bin/sh
/ # exit

使用 Ctrl + P + Q 组合键,退出容器但并不终止容器运行。这样做会切回到 Docker 主机的终端上,并保持容器在后台运行。如果你觉得组合键使用麻烦,Docker 还提供了 -d 参数在容器启动的时候就让容器在后台以守护态(Daemonized)形式运行,docker run -d alpine /bin/sh

[aspire@localhost apps]$ docker start -ai 202
/ #  -- 这里使用 Ctrl + P + Q 组合键切出当前容器
[aspire@localhost apps]$ docker container ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
202c216323e8        alpine              "/bin/sh"           6 minutes ago       Up 24 seconds                           relaxed_rubin

通过 docker container exec 或者 docker attach 命令 将终端重新连接到 Docker, docker container exec 这个命令接收两个参数,一个是正在运行的容器 ID ,另一个是要连接到容器内的哪个应用。docker attach 相对来说更为简单,接收容器的名字或 ID,但是只能连接到容器启动时指定的应用上。

[aspire@localhost ~]$ docker exec -it busy_bouman /bin/sh
/ # read escape sequence[aspire@localhost ~]$ docker container ls
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
e1e4b3628c5e        alpine              "/bin/sh"           50 seconds ago      Up 49 seconds                           busy_bouman[aspire@localhost ~]$ docker attach busy_bouman
/ #

问题: 如下执行结果所示,practical_pike 容器是通过 docker create 创建的容器,ecstatic_snyder 是通过 docker run 方式创建并运行过的容器。使用命令 docker create 创建的容器,然后以 docker start -ai 启动容器时,并没有连接到终端上,总是直接退出。但是通过 docker run 命令创建的容器再次以 docker start -ai 方式启动时,能顺利进入终端上。

[root@localhost apps]# docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                      PORTS               NAMES
f6387d8abf6f        alpine              "/bin/sh"           43 minutes ago      Exited (0) 13 minutes ago                       practical_pike
2b7875ccb293        alpine              "/bin/sh"           45 minutes ago      Exited (0) 19 minutes ago                       ecstatic_snyder
b99b9016b774        hello-world         "/hello"            50 minutes ago      Exited (0) 50 minutes ago                       tender_jones[root@localhost f6387d8abf6fa443ef7774f78b5dcece4b9f26cb76cbd55eefeeb8949f16211d]# docker start -ai practical_pike
[root@localhost f6387d8abf6fa443ef7774f78b5dcece4b9f26cb76cbd55eefeeb8949f16211d]#[root@localhost apps]# docker start -ai ecstatic_snyder
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var
/ # exit
[root@localhost apps]#

使用命令 docker inspect 分析两个容器有什么不同,最终我发现在 JSON 文件的 Config 中,不能进入终端的那个容器的 AttachStdin、Tty、OpenStdin 和 StdinOnce 的值是 false,通过 docker run 创建的容器这些值是 true ,其他的配置都相同。

[root@localhost apps]# docker inspect f6
[{"Id": "f6387d8abf6fa443ef7774f78b5dcece4b9f26cb76cbd55eefeeb8949f16211d","Created": "2020-03-10T08:51:47.941884949Z","Path": "/bin/sh","Args": [],"State": {"Status": "exited","Running": false,"Paused": false,"Restarting": false,"OOMKilled": false,"Dead": false,"Pid": 0,"ExitCode": 0,"Error": "","StartedAt": "2020-03-10T09:22:05.228469265Z","FinishedAt": "2020-03-10T09:22:05.227570527Z"},// 省略了一些其他信息"Config": {"Hostname": "f6387d8abf6f","Domainname": "","User": "","AttachStdin": false,"AttachStdout": true,"AttachStderr": true,"Tty": false,"OpenStdin": false,"StdinOnce": false,"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd": ["/bin/sh"],"Image": "alpine","Volumes": null,"WorkingDir": "","Entrypoint": null,"OnBuild": null,"Labels": {}},// 省略了一些其他信息}}
]

于是,进入目录 /var/lib/docker/containers/{containerId}/ 目录下,试着修改 config.v2.json 文件,将这些值改为 true 之后,重启启动容器,发现 config.v2.json 文件又恢复到了之前的状态,这些值还是 false 。还是不能进入容器终端。可以看出,docker create 创建的容器和 docker run 创建的容器还是有不同之处,通过这个问题我大致可以理解为什么都是通过 docker run 命令来第一次创建容器,不仅方便,而且规避了一些问题。这个问题暂且我也不知道为什么,先记录下来。

容器的重启策略

Docker 还提供了容器的重启策略来进行容器的自我修复,给命令 docker run 添加 --restart 参数来配置重启策略。容器的重启策略有四个值:no、always、unless-stopped 和 on-failure。

  • no :默认值,容器退出后不重启。

  • always : 除非容器被明确停止,比如通过 docker container stop 命令,否则该策略会一直尝试重启处于停止状态的容器。当 Docker 重启的时候,停止的容器也会被重启。如下所示,使用 exit 退出后查看运行着的容器,发现容器已经重启。

    [aspire@localhost apps]$ docker run -it --restart=always alpine
    / # exit
    [aspire@localhost apps]$ docker container ls
    CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
    6fabc0aefa2b        alpine              "/bin/sh"           10 seconds ago      Up 6 seconds                            charming_easley
    
  • unless-stopped 和 always 几乎相同,区别在于不会在 Docker daemon (暂且理解为 Docker 服务)重启的时候被重启。

  • on-failure:会在退出容器并且返回值不是 0 的时候,重启容器。on-failure 还可以指定重启的次数,下面的实例意思是如果容器非正常退出,最多尝试 3 次重启。

    [aspire@localhost apps]$ docker run -it --restart=on-failure:3 alpine
    / # exit[aspire@localhost apps]$ docker container ls
    CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
    

关闭容器

使用命令 docker container stop 命令或者 docker stop 来优雅的停止容器,该命令接收容器 ID 或者容器名称。docker stop 命令会向运行中的容器发送一个 SIGTERM 的信号,为进程预留一个清理并优雅停止的机会。如果 10s 内进程没有终止,那么就会收到 SIGKILL 信号。因此应该用此命令来关闭容器而不是 docker container rm <container> -f 或者 docker kill

删除容器

使用命令 docker container rm 命令或者 docker rm 来删除容器,该命令会移除构成容器的可读写层,但是这个命令只能对非运行态容器执行,这点容易理解。

容器的持久化

在介绍容器的时候说明过,容器存储层在容器退出后并不会被清理,除非容器被删除。接下来验证容器本身具有持久化功能。如下实例,根据镜像 alpine 创建容器并启动,在系统中的 tmp 目录下新建一个文件并写入内容,然后 Ctrl + P + Q 切到主机终端,停掉容器。

[aspire@localhost apps]$ docker run -it alpine
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var
/ # cd tmp
/tmp # ls -l
total 0
/tmp # echo "DevOps FTW" > newfile
/tmp # ls -l
total 4
-rw-r--r--    1 root     root            11 Mar 10 12:22 newfile
/tmp # cat newfile
DevOps FTW
/tmp #[aspire@localhost apps]$ docker container ls
CONTAINER ID        IMAGE               COMMAND             CREATED              STATUS              PORTS               NAMES
045433e78750        alpine              "/bin/sh"           About a minute ago   Up About a minute                       funny_archimedes[aspire@localhost apps]$ docker container stop funny_archimedes
funny_archimedes

然后,重新启动该容器,并查看文件。

[aspire@localhost apps]$ docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                       PORTS               NAMES
045433e78750        alpine              "/bin/sh"           4 minutes ago       Exited (137) 2 minutes ago                       funny_archimedes
b99b9016b774        hello-world         "/hello"            4 hours ago         Exited (0) 13 minutes ago                        tender_jones[aspire@localhost apps]$ docker start -ai funny_archimedes
/ # cd tmp/
/tmp # ls -l
total 4
-rw-r--r--    1 root     root            11 Mar 10 12:22 newfile
/tmp # cat newfile
DevOps FTW
/tmp #

虽然 Docker 容器具有持久化的功能,但是容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失。

按照 Docker 最佳实践的要求,容器不应该向其存储层内写入任何数据,容器存储层要保持无状态化。所有的文件写入操作,都应该使用 数据卷(Volume)、或者绑定宿主目录,在这些位置的读写会跳过容器存储层,直接对宿主(或网络存储)发生读写,其性能和稳定性更高。

数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此,使用数据卷后,容器删除或者重新运行之后,数据却不会丢失。

五、数据卷 volumes

什么是数据卷

虽然 Docker 具有持久化的特性,但是容器存储层的持久化从属于容器,生命周期与容器相同,这意味着删除容器也会删除全部非持久化数据。Docker 推荐使用卷的方式做数据的持久化。数据卷是一个可供一个或多个容器使用的特殊目录。用户创建卷,然后创建容器,接着将卷挂载到容器上。卷会挂载到容器文件系统的某个目录之下,任何写到该目录下的内容都会写到卷中,即使容器被删除,卷与其上面的数据仍然存在。

创建一个数据卷,Docker 会在主机上为这个数据卷分配一个存储路径,默认为 /var/lib/docker/volumes/{容器名}/_data 。然后将这个数据卷与容器中的某个目录(假设是 /tmp)对应起来,这个过程称为挂载。于是,在容器内部,如果你想将一些数据保存起来,往容器中的 /tmp 目录中写入数据, 其实就是在往主机中的 /var/lib/docker/volumes/{容器名}/_data 目录中写数据。因为数据实际上是写入到了容器外部的主机上,因此卷和容器是松耦合的,容器消失,数据还在。

数据卷常用命令

使用命令 docker volume create 命令创建一个数据卷,使用命令 docker volume ls 命令查看数据卷,返回的列表中有一列是 DRIVER 驱动,值为 local 。默认情况下,Docker 创建新卷时采用内置的 local 驱动,也就是说创建的数据卷都在本机,local 下的卷只能被本机上的容器使用。当然, Docker 允许外部卷接入,外部存储系统通过驱动或者插件的方式接入主机,这些外部驱动提供了高级存储特性,并为 Docker 集成了外部存储系统。现在有很多优秀的外部存储系统,并适用于不同类型的存储。例如,擅长文件存储的 Azure 文件存储以及 Amazon EFS。擅长对象存储的 Amazon S3 等。
下面示例创建一个名为 myVolume 的数据卷并查看主机上的数据卷。

[aspire@localhost ~]$ docker volume create myVolume
myVolume[aspire@localhost ~]$ docker volume ls
DRIVER              VOLUME NAME
local               myVolume

使用命令 docker volume inspect 来查看卷的详细信息。在下面的输出中,Mountpoint 的值就是数据卷的存储路径。

[aspire@localhost ~]$ docker volume inspect myVolume
[{"CreatedAt": "2020-03-11T20:15:59+08:00","Driver": "local","Labels": {},"Mountpoint": "/var/lib/docker/volumes/myVolume/_data","Name": "myVolume","Options": {},"Scope": "local"}
]

使用命令 docker volume prune 删除主机上所有的数据卷,使用命令 docker volume rm 删除指定的数据卷。注意,Docker 不允许删除正在被使用的数据卷,因此,无论使用那种命令删除数据卷都必须先保证数据卷没有被使用。

[aspire@localhost ~]$ docker volume prune
WARNING! This will remove all local volumes not used by at least one container.
Are you sure you want to continue? [y/N] y
Deleted Volumes:
myVolumeTotal reclaimed space: 0B[aspire@localhost ~]$ docker volume ls
DRIVER              VOLUME NAME

在用 docker run 命令的时候,使用 --mount 参数来将数据卷挂载到容器里。在一次 docker run 中可以挂载多个数据卷,需要注意的是,无法在 docker start 命令挂载数据卷,这点很好理解。 --mount 参数有两个值需要给定,第一个值 source ,是数据卷的名称,第二个值 target 是该数据卷要挂载到容器中的哪个目录。

[aspire@localhost ~]$ docker run -idt --mount source=myvolume,target=/tmp alpine
0f2f2e1cdb331414d565c5c82f6a8db08ae35cf4c72fef33f1625638c66286c4[aspire@localhost ~]$ docker container ls
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
0f2f2e1cdb33        alpine              "/bin/sh"           7 seconds ago       Up 6 seconds                            wizardly_hamilton[aspire@localhost ~]$ docker volume ls
DRIVER              VOLUME NAME
local               myvolume

需要注意的是,在执行上面的命令中,我并没有使用 docker volume create 命令来创建卷,仅仅是在 docker run 命令中指定了卷的名字,由此可见,如果这个卷不存在,那么 Docker 会自动创建这个数据卷。接下来连接到容器的终端,在 /tmp 目录下保存 “hello world” 数据。

[aspire@localhost ~]$ docker attach wizardly_hamilton
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var
/ # cd /tmp/
/tmp # echo "hello world" > file1
/tmp # cat file1
hello world
/tmp #

使用 Ctrl + P + Q 组合键切到主机上,进入目录 /var/lib/docker/volumes/ 中查看数据,发现和容器中 /tmp 目录下完全一致。即使容器退出或者被删除,该数据和数据卷也存在。

[root@localhost aspire]# cd /var/lib/docker/volumes/
[root@localhost volumes]# ll
总用量 24
-rw-------. 1 root root 32768 3月  11 20:48 metadata.db
drwxr-xr-x. 3 root root    19 3月  11 20:48 myvolume
[root@localhost volumes]# cd myvolume/
[root@localhost myvolume]# ll
总用量 0
drwxrwxrwt. 2 root root 19 3月  11 20:52 _data
[root@localhost myvolume]# cd _data/
[root@localhost _data]# ll
总用量 4
-rw-r--r--. 1 root root 12 3月  11 20:52 file1
[root@localhost _data]# cat file1
hello world

如果觉得使用 --mount 麻烦,也可以使用 -v 参数来挂载数据卷,使用分号来分割数据卷和挂载目录。例如命令 docker run -idt -v myvolume:/tmp alpine 的意思是将数据卷 myvolume 挂载到容器中的 /tmp 目录下。

使用 -v 参数还可以手动指定卷的存储路径。docker run -idt -v /apps/tmp:/tmp alpine 的意思是将主机上的 /apps/tmp 目录挂载到容器中的 /tmp 目录中。如果没有使用分号分割,比如 docker run -idt -v /tmp alpine,那么仍然将使用本地的默认路径。

Docker 挂载数据卷的默认权限是读写,通过 :ro 指定为只读,例如 docker run -idt -v /apps/tmp:/tmp:ro alpine

六、定制镜像

将应用整合到容器中并且运行起来的这个过程,称为 “容器化”,也叫 “ Docker 化”。为什么要进行容器化,还要回到 Docker 的概念上去,Docker 想要屏蔽外部环境对应用的影响,Bulid 、ship、run any app/any where。只要构建好带有应用的镜像,就能将这个镜像运行在任何地方。应用容器化最关键的步骤就是定制镜像。

镜像是多层存储,每一层都是在前一层的基础上进行修改,而容器同样也是多层存储,是在以镜像为基础层,在其基础上加一层作为容器运行时的存储层。现在让我们以定制一个 Nginx 为例子,来讲解镜像是如何构建的。

[aspire@localhost apps]$ docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
68ced04f60ab: Pull complete
28252775b295: Pull complete
a616aa3b0bf2: Pull complete
Digest: sha256:2539d4344dd18e1df02be842ffc435f8e1f699cfc55516e2cf2cb16b7a9aea0b
Status: Downloaded newer image for nginx:latest
docker.io/library/nginx:latest[aspire@localhost apps]$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
nginx               latest              6678c7c2e56c        7 days ago          127MB
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB
hello-world         latest              fce289e99eb9        14 months ago       1.84kB[aspire@localhost apps]$ docker run --name webserver -d -p 80:80 nginx
47252e8a6cb63d06e93cf2e6e6be7562428d0eabe76eb04d161a5eb88468ac5b[aspire@localhost apps]$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                NAMES
47252e8a6cb6        nginx               "nginx -g 'daemon of…"   9 seconds ago       Up 8 seconds        0.0.0.0:80->80/tcp   webserver

docker run 命令中添加 -p 参数,将 Docker 主机的端口映射到容器内,也就是说访问主机的80端口相当于访问容器的80端口。如果只指定了一个端口,那么指定的是容器端口,例如:-p 127.0.0.1::5000,此时本地主机会自动分配一个端口来映射到容器的5000端口。使用 docker port 来查看当前映射的端口配置,也可以查看到绑定的地址。现在在主机上访问 localhost(HTTP 默认80端口),看到默认的 Nginx 欢迎页面。
现在,我们进入容器中,修改 Nginx 的欢迎页面。刷新浏览器,将会看到最新效果。这里将无法使用 docker attach 命令,因为 /bin/bash 不是容器的默认进程。

[aspire@localhost apps]$ docker exec -it webserver /bin/bash
root@47252e8a6cb6:/# echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
root@47252e8a6cb6:/# exit
exit

可以通过 docker diff 命令查看对容器的改动内容。

[aspire@localhost apps]$ docker diff webserver
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
C /root
A /root/.bash_history
C /run
A /run/nginx.pid
C /usr
C /usr/share
C /usr/share/nginx
C /usr/share/nginx/html
C /usr/share/nginx/html/index.html

现在我们定制好了变化,我们希望能将其保存下来形成镜像。要知道,当我们使用容器的时候,我们做的任何改动都会被记录于容器存储层里。而 Docker 提供了一个 docker commit 命令,可以将容器的存储层保存下来做成只读层,这样容器就成为了镜像。换句话说,就是在原有镜像的基础上,再叠加上层,并构成新的镜像。以后我们运行这个新镜像的时候,就会拥有原有容器最后的文件变化。该命令在构成新镜像的时候必须指定镜像仓库的名字和标签,其中 --author 是指定修改的作者,而 --message 则是记录本次修改的内容,不过这里这些信息可以省略留空。

[aspire@localhost apps]$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                NAMES
47252e8a6cb6        nginx               "nginx -g 'daemon of…"   24 minutes ago      Up 19 minutes       0.0.0.0:80->80/tcp   webserver[aspire@localhost apps]$ docker commit --author "myname" --message "change index.html" webserver nginx:v2
sha256:43b2f77bf09bc59214accf614f61ab63aa56189adf6641a2a466d0edf30be065[aspire@localhost apps]$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
nginx               v2                  43b2f77bf09b        4 seconds ago       127MB
nginx               latest              6678c7c2e56c        7 days ago          127MB
alpine              latest              e7d92cdc71fe        7 weeks ago         5.59MB
hello-world         latest              fce289e99eb9        14 months ago       1.84kB

使用 docker history 来查看镜像的构建历史,可以看到,刚刚构建的最新一层在最上面。

[aspire@localhost apps]$ docker history 43b
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
43b2f77bf09b        3 minutes ago       nginx -g daemon off;                            97B                 change index.html
6678c7c2e56c        7 days ago          /bin/sh -c #(nop)  CMD ["nginx" "-g" "daemon…   0B
<missing>           7 days ago          /bin/sh -c #(nop)  STOPSIGNAL SIGTERM           0B
<missing>           7 days ago          /bin/sh -c #(nop)  EXPOSE 80                    0B
<missing>           7 days ago          /bin/sh -c ln -sf /dev/stdout /var/log/nginx…   22B
<missing>           7 days ago          /bin/sh -c set -x     && addgroup --system -…   57.6MB
<missing>           7 days ago          /bin/sh -c #(nop)  ENV PKG_RELEASE=1~buster     0B
<missing>           7 days ago          /bin/sh -c #(nop)  ENV NJS_VERSION=0.3.9        0B
<missing>           7 days ago          /bin/sh -c #(nop)  ENV NGINX_VERSION=1.17.9     0B
<missing>           2 weeks ago         /bin/sh -c #(nop)  LABEL maintainer=NGINX Do…   0B
<missing>           2 weeks ago         /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>           2 weeks ago         /bin/sh -c #(nop) ADD file:e5a364615e0f69616…   69.2MB

至此,我们就定制好了一个镜像,以这个镜像启动容器,就可以看到和之前的容器一样的效果。

使用 docker commit 命令虽然可以比较直观的帮助理解镜像分层存储的概念,但是实际环境中并不会这样使用。如果仔细观察之前的 docker diff webserver 的结果,你会发现除了真正想要修改的 /usr/share/nginx/html/index.html 文件外,由于命令的执行,还有很多文件被改动或添加了。这还仅仅是最简单的操作,如果是安装软件包、编译构建,那会有大量的无关内容被添加进来,如果不小心清理,将会导致镜像极为臃肿。

此外,使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为黑箱镜像,换句话说,就是除了制作镜像的人知道执行过什么命令、怎么生成的镜像,别人根本无从得知。而且,即使是这个制作镜像的人,过一段时间后也无法记清具体的操作。这种黑箱镜像的维护工作是非常痛苦的。

因此,不要使用 docker commit 定制镜像,定制镜像应该使用 Dockerfile 来完成。但是这并不意味着该命令没有用,比如被入侵后保存现场等。

七、Dockerfile

从刚才的 docker commit 的学习中,我们可以了解到,镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,这个脚本就是 Dockerfile。通过 Dockerfile 文件,能很清楚的看到每层是如何构建的,就像代码一样,并且 Dockerfile 文件体积小,容易被共享。

Dockerfile 是一个文本文件,其内包含了一条条的指令,每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。还以之前定制 nginx 镜像为例,这次我们使用 Dockerfile 来定制。

建议编写的 Dockerfile 文件在一个空白的目录中,因为在最终通过 Dockerfile 构建镜像的时候会把该路径下所有内容发送给 Docker 服务端,由服务端来创建镜像,这一点请先有大致印象,马上就会详细讲到。接下来创建这个 Dockerfile 脚本文件。

[aspire@localhost apps]$ mkdir mynginx
[aspire@localhost apps]$ cd mynginx/
[aspire@localhost mynginx]$ touch Dockerfile
[aspire@localhost mynginx]$ vim Dockerfile

在 Dockerfile 文件中加入以下内容:

FROM nginx
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html

FROM 指令

FROM 和 RUN 是两条指令,FROM 指定基础镜像,定制镜像必须以一个镜像为基础,在其上进行定制。因此在一个 Dockerfile 中 FROM 是必备的指令,并且必须是第一条指令。

在 Docker Hub 上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginx、redis、mongo、mysql、httpd、php、tomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 node、openjdk、python、ruby、golang 等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。

如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如 ubuntu、debian、centos、fedora、alpine 等,这些操作系统的软件库为我们提供了更广阔的扩展空间。

除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为 scratch。这个镜像是虚拟的概念,实际并不存在,它表示一个空白的镜像。如果你以 scratch 为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。

不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见,比如 swarm、etcd。对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接 FROM scratch 会让镜像体积更加小巧。使用 Go 语言 开发的应用很多会使用这种方式来制作镜像,这也是为什么有人认为 Go 是特别适合容器微服务架构的语言的原因之一。

RUN 指令

RUN 指令是用来执行命令行命令的。由于命令行的强大能力,RUN 指令在定制镜像时是最常用的指令之一。其格式有两种:

  • shell 格式:RUN <命令>,就像直接在命令行中输入的命令一样。刚才写的 Dockerfile 中的 RUN 指令就是这种格式。
  • exec 格式:RUN ["可执行文件", "参数1", "参数2"],这更像是函数调用中的格式。

Dockerfile 中每一个指令都会建立一层,RUN 也不例外。每一个 RUN 的行为都会给镜像新建立一层。如下所示的 Dockerfile 文件将编译、安装 redis ,但是却给镜像创建了 7 层,这是非常错误的写法,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。在构建镜像的过程中,要尽量减少镜像的层数,尽量减少与运行无关的文件。

FROM debian:stretchRUN apt-get update
RUN apt-get install -y gcc libc6-dev make wget
RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz"
RUN mkdir -p /usr/src/redis
RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
RUN make -C /usr/src/redis
RUN make -C /usr/src/redis install

因此,上面的 Dockerfile 的正确写法如下,减少镜像层数,并删除一些无关文件,在最后还清理了 apt 缓存文件。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。另一方面,这里为了格式化还进行了换行。Dockerfile 支持 Shell 类的行尾添加 \ 的命令换行方式,以及以 # 开头进行注释的格式。良好的格式,比如换行、缩进、注释等,会让维护、排障更为容易,这是一个比较好的习惯。

FROM debian:stretchRUN buildDeps='gcc libc6-dev make wget' \&& apt-get update \&& apt-get install -y $buildDeps \&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \&& mkdir -p /usr/src/redis \&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \&& make -C /usr/src/redis \&& make -C /usr/src/redis install \&& rm -rf /var/lib/apt/lists/* \&& rm redis.tar.gz \&& rm -r /usr/src/redis \&& apt-get purge -y --auto-remove $buildDeps

构建镜像

编写好了 Dockerfile 文件之后,可以通过 docker build 命令来构建镜像。-t 参数指定镜像构建的仓库名和标签,在命令末尾指定 Docker Context 路径,很快就会详细解释什么是 Docker Context。

[aspire@localhost mynginx]$ ll
总用量 4
-rw-rw-r--. 1 aspire aspire 82 3月  12 13:40 Dockerfile[aspire@localhost mynginx]$ docker build -t nginx:v2 .
Sending build context to Docker daemon  2.048kB
Step 1/2 : FROM nginx---> 6678c7c2e56c
Step 2/2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html---> Running in 2f6c66d69470
Removing intermediate container 2f6c66d69470---> 10de2ea8cc83
Successfully built 10de2ea8cc83
Successfully tagged nginx:v2

从命令的输出结果中,我们可以清晰的看到镜像的构建过程。注意在 Step 2 的过程中,首先 Docker 启动了一个容器 2f6c66d69470 ,然后在容器中执行了 RUN 命令并自动提交这一层,然后移除了这个临时容器。构建完毕后,就可以看到新的镜像,通过该镜像启动容器,结果和手动 docker commit 方式生成的镜像一样。

另一方面,docker build 命令还支持多种形式的 Dockerfile 给定形式,在上面的示例中,没有显示指定 Dockerfile 路径,那么,默认就是当前路径并且名字为 Dockerfile 的文件作为 Dockerfile。也可以给定一个 Dockerfile 的 URL 地址,或者一个 tar 压缩包等都可以。

Docker Context

docker build 命令结尾有一个 .,代表当前路径,而 Dockerfile 恰恰就在当前目录,因此不少初学者以为这个路径是在指定 Dockerfile 所在路径,这么理解其实是不准确的,这个路径指定的是 Context 路径。

首先我们要理解 docker build 的工作原理。Docker 在运行时分为 Docker 引擎(也就是服务端守护进程)和客户端工具。Docker 的引擎提供了一组 REST API,被称为 Docker Remote API,而如 docker imagesdocker container 等命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。Docker 守护进程 (Daemon)作为服务端接受来自客户端的请求,并处理这些请求(创建、运行、分发容器)。因此,虽然表面上我们好像是在本机执行各种 docker 功能,但实际上,一切都是使用的远程调用形式在服务端(Docker 引擎)完成。在之前的所有示例中,都是客户端和服务端运行在一个机器上,但是也可通过 socket 或者 RESTful API 来进行通信。

当我们进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。而 docker build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。当构建的时候,用户会指定构建镜像上下文的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

在 Dockerfile 中还有一个指令 COPY ,复制本地主机的一个文件到容器中的指定目录里。如果在 Dockerfile 中这么写:

COPY ./package.json /app/

Docker 引擎在执行这句命令时,并不是要复制执行 docker build 命令所在的目录下的 package.json,也不是复制 Dockerfile 所在目录下的 package.json,而是复制 Context 目录下的 package.json,因为对于 Docker 引擎而言,根本不知道主机上的其他文件,只知道客户端打包上传的文件。因此,COPY 这类指令中的源文件的路径都是相对路径。这也是为什么下面这种路径没有作用的原因。因为这些路径已经超出了上下文的范围,Docker 引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。

COPY ../package.json /app
COPY /opt/xxxx /app

现在再会过来看 docker build 命令的输出,你会发现,在 Setp 1 之前就是发送上下文的过程。

[aspire@localhost mynginx]$ docker build -t nginx:v2 .
Sending build context to Docker daemon  2.048kB
Step 1/2 : FROM nginx---> 6678c7c2e56c
Step 2/2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html---> Running in 2f6c66d69470
Removing intermediate container 2f6c66d69470---> 10de2ea8cc83
Successfully built 10de2ea8cc83
Successfully tagged nginx:v2

那么为什么会有人误以为 . 是指定 Dockerfile 所在目录呢?这是因为在默认情况下,如果不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile 脚本文件,这样就可以理解为什么在 docker build 命令中并没有指定 Dockerfile 的路径也能运行了。

这只是默认行为,实际上 Dockerfile 的文件名并不要求必须为 Dockerfile,而且并不要求必须位于上下文目录中,比如可以用 -f 参数指定某个文件作为 Dockerfile。

当然,一般大家习惯性的会使用默认的文件名 Dockerfile,以及会将其置于镜像构建上下文目录中,这就像一种约定俗成的东西一样,一种规范做法。

接下来说一些 Dockerfile 的其他一些指令。

COPY 指令

在上一小节中已经初步接触了 COPY 指令,现在详细的介绍一下。COPY 和 RUN 指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。

COPY [--chown=<user>:<group>] <源路径>... <目标路径>
COPY [--chown=<user>:<group>] ["<源路径1>",... "<目标路径>"]

COPY 指令将从源路径的文件或目录复制到新的一层的镜像内的目标路径位置。再次强调,源路径是指 Context 路径中的路径。源路径可以是多个,甚至可以是通配符,其通配符规则要满足 Go 的 filepath.Match 规则,如:

COPY hom* /mydir/
COPY hom?.txt /mydir/

目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。此外,还需要注意一点,使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。另外,在使用该指令的时候还可以加上 --chown 参数来改变文件的所属用户及所属组。

COPY --chown=55:mygroup files* /mydir/
COPY --chown=bin files* /mydir/
COPY --chown=1 files* /mydir/
COPY --chown=10:11 files* /mydir/

ADD 指令

ADD 指令也能完成文件的复制操作,格式为 ADD --chown=<user>:<group> <源路径> <目标路径>,并能在此的基础上自动完成一些附加操作。比如源路径可以是一个 URL,这种情况下,Docker 引擎会试图去下载这个链接的文件放到目标路径去。下载后的文件权限自动设置为 600,如果这并不是想要的权限,那么还需要增加额外的一层 RUN 指令进行权限调整,所以不如直接使用 RUN 指令,然后使用 wget 或者 curl 工具下载、处理权限,然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。

只有一种场合适用于 ADD 指令,如果源路径为一个 tar 压缩文件的话,并且压缩格式为 gzip, bzip2 以及 xz 的情况下,ADD 指令将会自动解压缩这个压缩文件到目标路径去,但在某些情况下,如果我们真的是希望复制个压缩文件进去,而不解压缩,这时就不可以使用 ADD 命令了。

在 Docker 官方的Dockerfile 最佳实践文档中要求,尽可能的使用 COPY,因为 COPY 的语义很明确,就是复制文件而已,而 ADD 则包含了更复杂的功能,其行为也不一定很清晰。最适合使用 ADD 的场合,就是所提及的需要自动解压缩的场合。

另外需要注意的是,ADD 指令会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢。

CMD 指令

RUN 指令是创建 Docker 镜像的步骤,RUN 指令对 Docker 容器造成的改变是会被反映到创建的 Docker 镜像上的。一个Dockerfile 中可以有许多个 RUN 指令。CMD 指令是当 Docker 镜像被启动后,Docker 容器将会默认执行的命令。一个 Dockerfile 中只能有一个 CMD 指令,如果指定了多条命令,只有最后一条会被执行,同样,如果用户启动容器时候指定了运行的命令,则会覆盖掉 CMD 指定的命令。

比如,ubuntu 镜像默认的 CMD 是 /bin/bash,如果我们直接 docker run -it ubuntu 的话,会直接进入 bash。我们也可以在运行时指定运行别的命令,如 docker run -it ubuntu cat /etc/os-release。这就是用 cat /etc/os-release 命令替换了默认的 /bin/bash 命令了,输出了系统版本信息。

CMD 指令有两种格式:

  • shell 格式:CMD <命令>
  • exec 格式:CMD ["可执行文件", "参数1", "参数2"...]

在指令格式上,一般推荐使用 exec 格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号 ",而不要使用单引号。

如果使用 shell 格式的话,实际的命令会被包装为 sh -c 的参数的形式进行执行。比如:

CMD echo $HOME

在实际执行中,会将其变更为:

CMD [ "sh", "-c", "echo $HOME" ]

提到 CMD 就不得不提容器中应用在前台执行和后台执行的问题。Docker 不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机里面那样,用 systemd 去启动后台服务,容器内没有后台服务的概念。如果你在 CMD 指令中这样写:

CMD service nginx start

然后发现容器执行后就立即退出了。甚至在容器内去使用 systemctl 命令结果却发现根本执行不了。这就是因为没有搞明白前台、后台的概念,没有区分容器和虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。

对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西。而使用 service nginx start 命令,则是希望 upstart 来以后台守护进程形式启动 nginx 服务。而刚才说了 CMD service nginx start 会被理解为 CMD [ "sh", "-c", "service nginx start"],因此主进程实际上是 sh。那么当 service nginx start 命令结束后,sh 也就结束了,sh 作为主进程退出了,自然就会令容器退出。

正确的做法是直接执行 nginx 可执行文件,并且要求以前台形式运行。比如:

CMD ["nginx", "-g", "daemon off;"]

ENTRYPOINT 指令

上一节讲到,如果启动容器时,手动指定了 CMD,例如 docker run -it ubuntu cat /etc/os-release,那么 cat /etc/os-release 将成为容器启动时执行的命令,CMD 指令将被覆盖。而 ENTRYPOINT 指令与 CMD 指令作用几乎相同,也是指定容器启动后执行的程序,每个 Dockerfile 中只能有一个 ENTRYPOINT,当指定多个时,只有最后一个起效。

不同点在于,它不会和手动指定的 CMD 冲突,而将其作为 ENTRYPOINT 指令的参数,这样的设计其实大有可为,ENTRYPOINT 指令使 CMD 指令像变量一样传入启动启动命令,这样用不同的 CMD 指令启动容器得到不同的结果。

假设我们需要一个得知自己当前公网 IP 的镜像,那么可以先用 CMD 来实现:

FROM ubuntu:18.04
RUN apt-get update \&& apt-get install -y curl \&& rm -rf /var/lib/apt/lists/*
CMD [ "curl", "-s", "https://ip.cn" ]

假如我们使用 docker build -t myip . 来构建镜像的话,如果我们需要查询当前公网 IP,只需要执行:

[aspire@localhost mynginx]$ docker run myip
当前 IP:223.72.89.81 来自:北京市 移动

从上面的 CMD 中可以看到实质的命令是 curl,那么如果我们希望显示 HTTP 头信息,就需要加上 -i 参数,或者我还想加入 -l 参数, -v 参数等等,我需要根据不同的参数得到不同的结果。如果我们希望加入 -i 这参数,我们就必须重新完整的输入这个命令:

docker run myip curl -s https://ip.cn -i

这显然不是很好的解决方案,而使用 ENTRYPOINT 就可以解决这个问题。现在我们重新用 ENTRYPOINT 来实现这个镜像:

FROM ubuntu:18.04
RUN apt-get update \&& apt-get install -y curl \&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "curl", "-s", "https://ip.cn" ]

这次我们再来尝试直接使用 docker run myip -i

[aspire@localhost mynginx]$ docker run myip
当前 IP:223.72.89.81 来自:北京市 移动[aspire@localhost mynginx]$ docker run myip -i
HTTP/1.1 200 OK
Server: nginx/1.8.0
Connection: keep-alive当前 IP:223.72.89.81 来自:北京市 移动

CMD 指令的内容将会作为参数传给 ENTRYPOINT 指令。这样就使得容器的启动更为灵活,通过不同的指令启动,能得到不同的结果。官方镜像 redis 就是这么做的:

FROM alpine:3.4
RUN addgroup -S redis && adduser -S -G redis redis
ENTRYPOINT ["docker-entrypoint.sh"]CMD [ "redis-server" ]

可以看到其中为了 redis 服务创建了 redis 用户,并在最后指定了 ENTRYPOINT 为 docker-entrypoint.sh 脚本,该容器默认启动将执行 redis-server 。


if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; thenchown -R redis .exec su-exec redis "$0" "$@"
fiexec "$@"

该脚本的内容就是根据 CMD 的内容来判断,如果 CMD 的内容是 redis-server 的话,则切换到 redis 用户身份启动服务器,否则使用 root 身份执行。比如:

$ docker run -it redis id
uid=0(root) gid=0(root) groups=0(root)

ENV 指令

这个变量比较简单,这就是个环境变量,其他指令通过$符号使用这个值。格式有两种:

  • ENV <key> <value>
  • ENV <key1>=<value1> <key2>=<value2>

这个指令通过 node 官方镜像的 Dockerfile 实例即可:

ENV NODE_VERSION 7.2.0RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \&& curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \&& gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \&& grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \&& tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \&& rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \&& ln -s /usr/local/bin/node /usr/local/bin/nodejs

环境变量可以将 Dockerfile 做成一个模板一样的东西,如上所示,如果版本发生变化,只需要更改一处即可。

VOLUME 指令

关于 volume 我们已经介绍过了,VOLUME 指令就是将指定某些目录挂载为匿名卷。格式为:

  • VOLUME ["<路径1>", "<路径2>"...]
  • VOLUME <路径>

下面的实例就是将一个匿名卷挂载到容器中的 /data 目录,任何写入该目录的都会写入到卷中。当然,也可以在 docker run 命令中显示的挂载卷到 /data 目录上,这样就替换了 Dockerfile 中的 VOLUME 指令。

VOLUME /data

EXPOSE 指令

EXPOSE 指令是声明运行时容器提供服务端口,这只是一个声明,在运行时并不会因为这个声明应用就会开启这个端口的服务。注意和之前章节中提到的 -p 区分开来。-p <宿主端口>:<容器端口>,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。格式为:EXPOSE <端口1> [<端口2>...]

WORKDIR 指令

WORKDIR 指令将为后续的 RUN、CMD、ENTRYPOINT 指令配置工作目录,如果目录不存在,WORKDIR 会帮你建立目录。如果 Dockerfile 写成如下这样:

RUN cd /app
RUN echo "hello" > world.txt

如果将这个 Dockerfile 进行构建镜像运行后,会发现找不到 /app/world.txt 文件。原因其实很简单,在 Dockerfile 中,这两行 RUN 命令的执行环境根本不同,是两个完全不同的容器。之前说过每一个 RUN 都是启动一个容器、执行命令、然后提交存储层文件变更。第一层 RUN cd /app 的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR 指令,该命令格式为:

WORKDIR <工作目录路径>

上面的 Dockerfile 应该这样写:

WORKDIR /app
RUN echo "hello" > world.txt

一个 Dockerfile 文件中可以使用多个 WORKDIR 指令,后续命令如果参数是相对路径,则会基于之前命令指定的路径。如下 Dockerfile 构建的则最终路径为 /a/b/c。

WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd

USER 指令

USER 指令和 WORKDIR 相似,都是改变环境状态并影响以后的层。WORKDIR 是改变工作目录,USER 则是改变之后层的执行 RUN, CMD 以及 ENTRYPOINT 这类命令的身份。格式:USER <用户名>[:<用户组>] 。USER 指令只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。如下示例,创建一个 redis 用户并使用这个用户:

RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]

要临时获取管理员权限可以使用 gosu,而不推荐 sudo。docker 容器中运行的进程,如果以 root 身份运行的会有安全隐患,该进程拥有容器内的全部权限,更可怕的是如果有数据卷映射到宿主机,那么通过该容器就能操作宿主机的文件夹了,一旦该容器的进程有漏洞被外部利用后果是很严重的。因此,容器内使用非 root 账号运行进程才是安全的方式。

# 建立 redis 用户,并使用 gosu 换另一个用户执行命令
RUN groupadd -r redis && useradd -r -g redis redis
# 下载 gosu
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64" \&& chmod +x /usr/local/bin/gosu \&& gosu nobody true
# 设置 CMD,并以另外的用户执行
CMD [ "exec", "gosu", "redis", "redis-server" ]

HEALTHCHECK 指令

HEALTHCHECK 指令是告诉 Docker 应该如何进行判断容器的状态是否正常,这是 Docker 1.12 引入的新指令,该指令只可以出现一次,如果写了多个,只有最后一个生效。

在没有 HEALTHCHECK 指令前,Docker 引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。在 1.12 以前,Docker 不会检测到容器的这种状态,从而不会重新调度,导致可能会有部分容器已经无法提供服务了却还在接受用户请求。

HEALTHCHECK 指令格式:

HEALTHCHECK [选项] CMD <命令>

HEALTHCHECK 指令后跟一个 CMD 指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。当在一个镜像指定了 HEALTHCHECK 指令后,用其启动容器,初始状态会为 starting,在 HEALTHCHECK 指令检查成功后变为 healthy,如果连续一定次数失败,则会变为 unhealthy。

HEALTHCHECK 支持下列选项:

  • --interval=<间隔>:两次健康检查的间隔,默认为 30 秒
  • --timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认 30 秒
  • --retries=<次数>:当连续失败指定次数后,则将容器状态视为 unhealthy,默认 3 次

CMD 指令的返回值决定了该次健康检查的成功与否:0:成功;1:失败;2:保留,不要使用 2 这个值。假设我们有个镜像是个最简单的 Web 服务,我们希望增加健康检查来判断其 Web 服务是否在正常工作,我们可以用 curl 来帮助判断,其 Dockerfile 的 HEALTHCHECK 可以这么写:

FROM nginx
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
HEALTHCHECK --interval=5s --timeout=3s \CMD curl -fs http://localhost/ || exit 1

使用 docker build 来构建这个镜像,然后启动一个容器:

$ docker build -t myweb:v1 .
$ docker run -d --name web -p 80:80 myweb:v1

当运行该镜像后,可以通过 docker container ls 看到最初的状态为 (health: starting):

$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                            PORTS               NAMES
03e28eb00bd0        myweb:v1            "nginx -g 'daemon off"   3 seconds ago       Up 2 seconds (health: starting)   80/tcp, 443/tcp     web

在等待几秒钟后,再次 docker container ls,就会看到健康状态变化为了 (healthy):

$ docker container ls
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                    PORTS               NAMES
03e28eb00bd0        myweb:v1            "nginx -g 'daemon off"   18 seconds ago      Up 16 seconds (healthy)   80/tcp, 443/tcp     web

为了帮助排障,健康检查命令的输出(包括 stdout 以及 stderr)都会被存储于健康状态里,可以用 docker inspect 来查看。

ONBUILD 指令

ONBUILD 是一个条件指令,在当前镜像构建时并不会被执行。格式为:

ONBUILD <其它指令>

ONBUILD 指令中的指令,只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。例如,Dockerfile 使用如下的内容创建了镜像 image-A,image-A 镜像的构建过程并不会执行 ONBUILD 中的指令。

ONBUILD ADD . /app/src
ONBUILD RUN /usr/local/bin/python-build --dir /app/src

如果基于 image-A 创建新的镜像时,会自动执行ONBUILD 指令内容,等价于在后面添加了两条指令。

FROM image-A#当前 Dockerfile 文件中只要一行 FROM image-A, 但是其实还包含如下两条指令
#ADD . /app/src
#RUN /usr/local/bin/python-build --dir /app/src

MAINTAINER 指令

MAINTAINER 指令没有什么特殊的意义,只是为了规范起见指定维护者信息。格式为:

MAINTAINER <名字>

需要注意的点:

  1. 指令是不区分大小写的,但是通常都采用大写的方式。这样 Dockerfile 的可读性会高一些。
  2. 并不是所有的指令都会创建新的镜像层,关于如何区分命令是否会新建镜像层,一个基本的原则是,如果指令的作用是向镜像中增添新的文件或者程序,那么这条指令就会新建镜像层;如果只是告诉 Docker 如何完成构建或者如何运行应用程序,那么就只会增加镜像的元数据。例如:新增镜像层的指令包括 FROM、RUN 以及 COPY,而EXPOSE、WORKDIR、ENV以 及 ENTERPOINT 等只会增加镜像的元数据。
  3. 利用 docker inspect 和 docker history 查看所构建的镜像。

多阶段构建镜像

Docker v17.05 开始支持多阶段构建 ,在Dockerfile 最佳实践文档中,提到,应该使用多阶段构建来减少所构建镜像的大小,越大则越慢,这就意味着更难使用,而且可能更加脆弱,更容易遭受攻击。多阶段构建可以在一个 Dockerfile 中使用多个 FROM 指令,每一个 FROM 指令都是一个新的构建阶段,使用 as 注明阶段名称,并且 Docker 默认会给每个阶段注上索引顺序,例如 builder 阶段也是第 0 阶段。每个阶段相互独立,可以通过 COPY --from 来获取其它阶段的文件。例如:

FROM openjdk:8u171-jdk-alpine3.8ADD . /app
WORKDIR /appRUN apk add maven \&& mvn clean package \&& apk del maven \&& mv target/final.jar / \&& cd / \&& rm -rf /app \&& rm -rf /root/.m2ENTRYPOINT java -jar /final.jar

优化之后的 Dockerfile 文件:

FROM openjdk:8u171-jdk-alpine3.8 as builderADD . /app
WORKDIR /appRUN apk add maven \&& mvn clean package \&& apk del maven \&& mv target/final.jar /FROM openjdk:8u181-jre-alpine3.8 as environment
WORKDIR /
COPY --from=builder /final.jar .
ENTRYPOINT java -jar /final.jar

在构建 environment 阶段时,只使用 builder 阶段的产物 final.jar,而其他的都不需要,这样,我们就得到了一个精简的生产环境,这个镜像必然小而快。

八、Docker 应用容器化

将应用整合到容器中并且运行起来的这个过程,称为“容器化”,也叫作“Docker化”。本章以一个Springboot 项目,来演示容器化的过程。

  1. 创建了一个 SpringBoot 项目,版本为 SringBoot-2.1.9 ,这个项目并没有什么特别之处,只有一个简单的 Controller 。

    @RestController
    public class BaseController {@RequestMapping("/getString")public String getString(){return "hello world";}
    }
    
  2. 本地测试没问题后,将项目打成 jar 包,demo-0.0.1-SNAPSHOT.jar,然后将 jar 包上传到虚拟机上(因为我 Windows 上没有装 Docker,一切 Docker 实验都是在自己的虚拟机上 CentOS8上 跑的)。

  3. jar 包目录为 /apps/demo 的空白目录,在这个目录中创建 Dockerfile 文件,文件内容如下,hub.c.163.com 是镜像中心 - 网易云镜像中心,可以看到,FROM 指令中的基础镜像其实是在 apline 上搭建了 java8 的镜像。

    FROM hub.c.163.com/library/java:8-alpineADD demo-0.0.1-SNAPSHOT.jar app.jarEXPOSE 8080ENTRYPOINT ["java", "-jar", "/app.jar"]
    
  4. 使用命令 docker build -t 构建镜像。

    [aspire@localhost demo]$ ll
    总用量 16452
    -rw-r--r--. 1 aspire aspire 16842614 3月  13 13:11 demo-0.0.1-SNAPSHOT.jar
    -rw-r--r--. 1 aspire aspire      137 3月  14 10:38 Dockerfile[aspire@localhost demo]$ docker build -t demo:v1 .
    Sending build context to Docker daemon  16.85MB
    Step 1/4 : FROM hub.c.163.com/library/java:8-alpine
    8-alpine: Pulling from library/java
    3690ec4760f9: Pull complete
    cfdb77eb56b4: Pull complete
    4f83c3a97867: Pull complete
    Digest: sha256:c9204c41ef03c554363927d7a720bb080c76a388d3ccc3d66a7888a9b15166ab
    Status: Downloaded newer image for hub.c.163.com/library/java:8-alpine---> d991edd81416
    Step 2/4 : ADD demo-0.0.1-SNAPSHOT.jar app.jar---> 0898adc4da10
    Step 3/4 : EXPOSE 8080---> Running in 13f7f86d500a
    Removing intermediate container 13f7f86d500a---> b1eb5c28d1b0
    Step 4/4 : ENTRYPOINT ["java", "-jar", "/app.jar"]---> Running in 9ee86c840456
    Removing intermediate container 9ee86c840456---> f5eb017738e9
    Successfully built f5eb017738e9
    Successfully tagged demo:v1
    
  5. 构建完成后,使用命令 docker images 查看所有镜像

    [aspire@localhost demo]$ docker images
    REPOSITORY                   TAG                 IMAGE ID            CREATED             SIZE
    demo                         v1                  f5eb017738e9        2 minutes ago       162MB
    nginx                        latest              6678c7c2e56c        9 days ago          127MB
    alpine                       latest              e7d92cdc71fe        8 weeks ago         5.59MB
    hello-world                  latest              fce289e99eb9        14 months ago       1.84kB
    hub.c.163.com/library/java   8-alpine            d991edd81416        3 years ago         145MB
    
  6. 以这个镜像启动容器,并映射端口:

    [aspire@localhost demo]$ docker run -d -p 8080:8080 demo:v1
    56aa33f8d2104c71efcd0747898183ee9712367ae5b9e3d182244d90ebf91cb0
    [aspire@localhost demo]$ docker container ls
    CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS              PORTS                    NAMES
    56aa33f8d210        demo:v1             "java -jar /app.jar"   10 seconds ago      Up 9 seconds        0.0.0.0:8080->8080/tcp   youthful_tharp
    
  7. 在主机中输入访问地址和端口,即可接口返回

  8. 使用 docker logs 命令查看容器启动日志

    [aspire@localhost demo]$ docker logs youthful_tharp .   ____          _            __ _ _/\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
    ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \\\/  ___)| |_)| | | | | || (_| |  ) ) ) )'  |____| .__|_| |_|_| |_\__, | / / / /=========|_|==============|___/=/_/_/_/:: Spring Boot ::        (v2.1.9.RELEASE)2020-03-14 03:01:00.604  INFO 1 --- [           main] com.example.demo.DemoApplication         : Starting DemoApplication v0.0.1-SNAPSHOT on 56aa33f8d210 with PID 1 (/app.jar started by root in /)
    2020-03-14 03:01:00.607  INFO 1 --- [           main] com.example.demo.DemoApplication         : No active profile set, falling back to default profiles: default
    2020-03-14 03:01:01.553  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
    2020-03-14 03:01:01.584  INFO 1 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
    2020-03-14 03:01:01.584  INFO 1 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.26]
    2020-03-14 03:01:01.663  INFO 1 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
    2020-03-14 03:01:01.663  INFO 1 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 998 ms
    2020-03-14 03:01:01.856  INFO 1 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
    2020-03-14 03:01:02.027  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
    2020-03-14 03:01:02.029  INFO 1 --- [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.75 seconds (JVM running for 2.037)
    2020-03-14 03:01:35.597  INFO 1 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
    2020-03-14 03:01:35.597  INFO 1 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
    2020-03-14 03:01:35.604  INFO 1 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 7 ms
    

本节只是简单的演示了项目容器化的过程,但是在实际使用过程中,往往是通过自动构建的,而且通常会有更复杂的 Dockerfile 文件。

九、Docker Compose

使用 Dockerfile 文件可以定义一个镜像该如何构建,然而微服务架构的应用系统一般包含若干个服务,每个服务一般都会部署多个实例,这意味着要将 Dockerfile 构建成的镜像,手动运行多个容器实例,并且难以管理。通过 Docker Compose ,可以通过简单的配置,然后会自动帮我们将一个服务启动若干个实例。然而 Docker Compose 提供的功能不止于此,在实际开发中,我们的应用需要数据库,需要 redis ,这些合起来才构成一个完整的项目。Docker Compose 还可以将这些其他的组件也合并起来进行统一的管理。

Docker Compose 是 docker 官方的开源项目,使用 python 编写,实现上调用了 Docker 服务的 API 进行容器管理。官方将对它的定位是 [ 定义和运行多个 Docker 容器的应用 ]。

Docker Compose 通过 docker-compose.yml 文件来定义一组相关联的应用容器为一个项目。项目,是 Docker Compose 的默认管理对象。

Docker Compose 的安装

接下来演示 Linux 上安装 Docker Compose ,Docker Desktop for Mac/Windows 自带 docker-compose 二进制文件,安装 Docker 之后可以直接使用。登录 https://github.com/docker/compose/releases 下载 Docker Compose。对于官网上推荐的那种使用 curl 方式下载的,由于网络问题,并没有成功,于是我通过离线下载。Docker Compose 在 Linux 上的使用,要先确保安装有 Docker 引擎。

下载完成后将 docker-compose-Linux-x86_64 文件放入 /usr/local/bin/ 下,并更名为 docker-compose 。最后增加执行权限:

[root@localhost bin]# chmod +x /usr/local/bin/docker-compose
[root@localhost bin]# ll
总用量 16776
-rwxr-xr-x. 1 root root 17176256 3月  14 20:30 docker-compose

使用命令 docker-compose version 检查是否安装成功。

[aspire@localhost ~]$ docker-compose -version
docker-compose version 1.25.4, build 8d51620a

使用 Docker Compose

Docker Compose 默认使用路径名作为项目名,默认的配置文件名为 docker-compose.yml。关于 YAML 文件的格式在这里就不介绍了,接下来以官方实例的 docker-compose.yml 文件来进行讲解 docker-compose.yml 的意思,虽然文件看起来多而复杂,但是在学过 docker 之后,大多 key 都是好理解的。

version: "3.7"
services:redis:image: redis:alpineports:- "6379"networks:- frontenddeploy:replicas: 2update_config:parallelism: 2delay: 10srestart_policy:condition: on-failuredb:image: postgres:9.4volumes:- db-data:/var/lib/postgresql/datanetworks:- backenddeploy:placement:constraints: [node.role == manager]vote:image: dockersamples/examplevotingapp_vote:beforeports:- "5000:80"networks:- frontenddepends_on:- redisdeploy:replicas: 2update_config:parallelism: 2restart_policy:condition: on-failureresult:image: dockersamples/examplevotingapp_result:beforeports:- "5001:80"networks:- backenddepends_on:- dbdeploy:replicas: 1update_config:parallelism: 2delay: 10srestart_policy:condition: on-failureworker:image: dockersamples/examplevotingapp_workernetworks:- frontend- backenddeploy:mode: replicatedreplicas: 1labels: [APP=VOTING]restart_policy:condition: on-failuredelay: 10smax_attempts: 3window: 120splacement:constraints: [node.role == manager]visualizer:image: dockersamples/visualizer:stableports:- "8080:8080"stop_grace_period: 1m30svolumes:- "/var/run/docker.sock:/var/run/docker.sock"deploy:placement:constraints: [node.role == manager]networks:frontend:backend:volumes:db-data:

首先可以注意到,它包含 4 个一级 key:version、services、networks、volumes。

version

version 是必须指定的,而且总是位于文件的第一行。它定义了 Compose 文件格式的版本。至于 docker-compose.yml 的版本和 docker 版本的对应关系,可以查看 官方文档。

services

services 定义了服务的配置信息,这是 Compose 中最重要的部分,他们共同组成一个项目。services 中的二级 key ,就是每个服务的名称,例如上面的 Compose 中,定义了 6 个服务。当启动 docker-compose 后,它会为每个服务都创建容器实例。接下来解释三级 key 的意思:

  • image:每个服务必须指定镜像,要不然 docker-compose 将无法为服务创建容器,当然,也可以使用 build 指定 Dockerfile 路径。

  • build:指定 Dockerfile 所在文件夹的路径(可以是绝对路径,或者相对 docker-compose.yml 文件的路径)。 Compose 将会利用它自动构建这个镜像,然后使用这个镜像。这个 key 下还可以有四级 key 。context 指令指定 Dockerfile 所在文件夹的路径。dockerfile 指令指定 Dockerfile 文件名。arg 指令指定构建镜像时的变量,就像 Dockerfile 中的 ENV 一样,但是 args 允许空值。例如:

    version: '3'services:webapp:build:context: ./dirdockerfile: Dockerfile-alternateargs:password: secret
    
  • ports:暴露端口信息。格式为:主机端口:容器端口,或者仅仅指定容器的端口(此时主机将会随机选择端口)。

  • networks:配置容器连接的网络,关于这一点可以参考一级 key : networks。

  • depends_on:说明该服务依赖于哪个服务,如上例所示, result 服务依赖于 db ,此时 compose 将先启动 db 再启动 result 服务,这只是标识了容器启动的先后顺序而已,result 服务并不会等待 db 服务启动完毕后再启动。

  • command:覆盖容器启动后默认执行的命令。

  • container_name:指定容器名称。默认将会使用 项目名称_服务名称_序号 这样的格式。

  • logging:配置日志选项,支持的 driver 类型有三种:json-file、syslog、none。其中 options 配置日志驱动的相关参数。

    logging:driver: syslogoptions:max-size: "200k"
    
  • volumes:使用数据卷,并可以指定访问模式, :ro。请配合一级 key volumes 理解。

  • deploy:这个 key 是配置 Swarm 集群相关的,在这里简单了解一下,具体参考 Docker Swarm 。mode 有两个值:global 和 replicated,global 意思是每个服务只有一个容器,replicated 意思是手动指定容器数量,如上所示,在指定了 mode 为 replicated,后 使用 replicas 指定了数量。restart_policy 对应 Docker 的重启策略。update_config 配置更新服务,用于无缝更新应用,delay:更新一组容器之间的等待时间。failure_action:如果更新失败,可以执行的的是 continue、rollback 或 pause (默认),monitor:每次任务更新后监视失败的时间(ns|us|ms|s|m|h)(默认为0),max_failure_ratio:在更新期间能接受的失败率,order:更新次序设置,可选的值有 stop-first(更新之前先停止旧的)、start-first(直接进行更新,然后再停止旧的,新旧有重叠)(默认 stop-first)

networks

networks 定义了网络信息,用于指引 Docker 创建新的网络,二级 key 为创建的网络名称。默认情况下,Docker Compose 会为你的项目创建一个名字为 “项目名_default” 的网络,这是一种单一网络,只能够实现加入到这个网络上的容器的连接。当然,也可以使用 driver 属性来指定不同的网络类型,当然更多的网络类型见官方文档。

假定,你的项目所在的文件路径为 myapp ,而你的 YAML 文件如下:

version: "3"
services:web:build: .ports:- "8000:8000"db:image: postgresports:- "8001:5432"

当启动 docker-compose 后,它会创建一个名为 myapp_default 的网络,然后将名为 web 的服务和名为 db 的服务都加入到这个网络中,于是这两个服务即可互相通信,例如,web 服务可以通过 URL postgres://db:5432 来连接 db 服务,要注意的是,web 服务连接的 db 服务端口是容器端口而非映射的主机端口,这是容器与容器直接的通信。

volumes

volumes 定义了卷信息,提供给 services 中的 具体容器使用。需要注意的是,如果你并不需要将这些卷重用,那么你就没有必要在一级 key volumes 中来声明他们。正如上面那份官方实例一样,可以注意到,在名为 visualizer 的服务里,单独定义了服务使用的卷。而对于一级 key volumes中声明的 db-data,在服务 db 中引用了。

Compose 命令说明

对于 Compose 来说,大部分命令的对象既可以是项目本身,也可以指定为项目中的服务或者容器。如果没有特别的说明,命令对象将是项目,这意味着项目中所有的服务都会受到命令影响。docker-compose 命令的基本的使用格式是:

docker-compose [-f=<arg>...] [options] [COMMAND] [ARGS...]

docker-compose 命令可以添加 -f 参数指定使用的 Compose 模板文件,-p 指定项目名称。

  • docker-compose build [options] [SERVICE...] 命令,构建(重新构建)项目中的服务容器。参数--force-rm 删除构建过程中的临时容器。参数--no-cache 构建镜像过程中不使用 cache(这将加长构建过程)。参数 --pull 始终尝试通过 pull 来获取更新版本的镜像。
  • docker-compose up 命令是最强大的命令,docker-compose 将尝试自动完成包括构建镜像,(重新)创建服务,启动服务,并关联服务相关容器的一系列操作,默认情况下 docker-compose up 启动的容器都在前台,控制台将会同时打印所有容器的输出信息。参数 -d 将会在后台启动并运行所有的容器。参数 --no-recreate 将只会启动处于停止状态的容器,而忽略已经运行的服务,默认情况,如果服务容器已经存在,docker-compose up 将会尝试停止容器,然后重新创建。参数--no-deps -d <SERVICE_NAME> 来重新创建服务并后台停止旧服务,启动新服务,并不会影响到其所依赖的服务。当通过 Ctrl + C 停止时,所有容器将会停止。
  • docker-compose down 命令,停止并删除运行中的 Compose 应用。它会删除容器和网络,但是不会删除卷和镜像。
  • docker-compose start [SERVICE...]命令,启动已经存在的服务容器。
  • docker-compose stop 命令,停止已经处于运行状态的容器,但不删除它。通过 docker-compose start 命令可以再次启动这些容器。
  • docker-compose rm [options] [SERVICE...] 命令,用于删除已停止的 Compose 应用。
  • docker-compose ps [options] [SERVICE...] 命令,用于列出 Compose 应用中的各个容器。推荐先执行 docker-compose stop 命令来停止容器。-v 删除容器所挂载的数据卷。
  • docker-compose images 命令,列出 Compose 文件中包含的镜像。
  • docker-compose exec 命令,进入指定的容器。
  • docker-compose kill [options] [SERVICE...] 命令,通过发送 SIGKILL 信号来强制停止服务容器。
  • docker-compose logs [options] [SERVICE...]命令,查看服务容器的输出。默认情况下,docker-compose 将对不同的服务输出使用不同的颜色来区分。可以通过 --no-color 来关闭颜色。
  • docker-compose pause [SERVICE...]命令,暂停一个服务容器。
  • docker-compose port [options] SERVICE PRIVATE_PORT命令,查看指定服务的私有端口所映射的公共端口。--index=index 如果同一服务存在多个容器,指定命令对象容器的序号(默认为 1)。
  • docker-compose restart [options] [SERVICE...]命令,重启项目中的服务。-t, --timeout 指定重启前停止容器的超时(默认为 10 秒)。
  • docker-compose run [options] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...]命令,在指定服务上执行一个命令。
  • docker-compose scale [options] [SERVICE=NUM...]命令,设置指定服务运行的容器个数。一般的,当指定数目多于该服务当前实际运行容器,将新创建并启动容器;反之,将停止容器。

Web 应用集成 Redis 实例

以 Springboot-2.1.9 和 redis 实现一个简单的访问计数的功能,做 docker-compose 的实例,首先,创建一个 springboot 项目,在 pom.xml 中加入 redis 依赖:

<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
</dependencies>

在 application.properties 配置文件中加入 redis.host 配置。

server.port=8080
spring.redis.host=redis

一个简单的 Controller ,访问的时候将 redis 对应的 key 值加一:

@RestController
public class BaseController {@Autowiredprivate RedisTemplate redisTemplate;@RequestMapping("/count")public String getCount(){Long result = redisTemplate.opsForValue().increment("hits", 1L);return result.toString();}
}

将项目打成 jar 包,上传到虚拟机的 /apps/demo 空白目录下:

[aspire@localhost apps]$ mkdir demo
[aspire@localhost apps]$ cd demo/
[aspire@localhost demo]$ rz
rz waiting to receive.
Starting zmodem transfer.  Press Ctrl+C to cancel.
Transferring demo-0.0.1-SNAPSHOT.jar...100%   24489 KB    24489 KB/sec    00:00:01       0 Errors

在当前目录中创建 Dockerfile 文件,内容如下:

FROM hub.c.163.com/library/java:8-alpineADD demo-0.0.1-SNAPSHOT.jar app.jarEXPOSE 8080ENTRYPOINT ["java", "-jar", "/app.jar"]

在当前目录中创建 docker-compose.yml 文件,内容如下:

version: '3'
services:web:build: .ports:- "8080:8080"redis:image: "redis:alpine"

最终,当前目录中的内容如下:

[aspire@localhost demo]$ pwd
/apps/demo
[aspire@localhost demo]$ ll
总用量 24500
-rw-r--r--. 1 aspire aspire 25076736 3月  16 19:50 demo-0.0.1-SNAPSHOT.jar
-rw-r--r--. 1 aspire aspire      118 3月  16 19:58 docker-compose.yml
-rw-r--r--. 1 aspire aspire      137 3月  16 19:58 Dockerfile

使用命令 docker-compose up 构建并启动容器,输出的内容有点多,如下所示:

[aspire@localhost demo]$ docker-compose up
Creating network "demo_default" with the default driver
Building web
Step 1/4 : FROM hub.c.163.com/library/java:8-alpine---> d991edd81416
Step 2/4 : ADD demo-0.0.1-SNAPSHOT.jar app.jar---> 6cd3b1e13999
Step 3/4 : EXPOSE 8080---> Running in cb733c35ba16
Removing intermediate container cb733c35ba16---> daf4dabdeabd
Step 4/4 : ENTRYPOINT ["java", "-jar", "/app.jar"]---> Running in f9e3f537f5d9
Removing intermediate container f9e3f537f5d9---> e0810f610c02
Successfully built e0810f610c02
Successfully tagged demo_web:latest
WARNING: Image for service web was built because it did not already exist. To rebuild this image you must use `docker-compose build` or `docker-compose up --build`.
Pulling redis (redis:alpine)...
alpine: Pulling from library/redis
c9b1b535fdd9: Already exists
8dd5e7a0ba4a: Pull complete
e20c1cdf5aef: Pull complete
25131c35a099: Pull complete
bd7c9740b22d: Pull complete
d4f86850c303: Pull complete
Digest: sha256:49a9889fc47003cc8b8d83bb008dacd3164f6f594caed5e7f1c6829f52c221a8
Status: Downloaded newer image for redis:alpine
Creating demo_redis_1 ... done
Creating demo_web_1   ... done
Attaching to demo_web_1, demo_redis_1
redis_1  | 1:C 16 Mar 2020 12:02:05.043 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
redis_1  | 1:C 16 Mar 2020 12:02:05.043 # Redis version=5.0.8, bits=64, commit=00000000, modified=0, pid=1, just started
redis_1  | 1:C 16 Mar 2020 12:02:05.043 # 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 16 Mar 2020 12:02:05.045 * Running mode=standalone, port=6379.
redis_1  | 1:M 16 Mar 2020 12:02:05.045 # 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 16 Mar 2020 12:02:05.045 # Server initialized
redis_1  | 1:M 16 Mar 2020 12:02:05.045 # 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 16 Mar 2020 12:02:05.045 # 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.
redis_1  | 1:M 16 Mar 2020 12:02:05.046 * Ready to accept connections
web_1    |
web_1    |   .   ____          _            __ _ _
web_1    |  /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
web_1    | ( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
web_1    |  \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
web_1    |   '  |____| .__|_| |_|_| |_\__, | / / / /
web_1    |  =========|_|==============|___/=/_/_/_/
web_1    |  :: Spring Boot ::        (v2.1.9.RELEASE)
web_1    |
web_1    | 2020-03-16 12:02:05.905  INFO 1 --- [           main] com.example.demo.DemoApplication         : Starting DemoApplication v0.0.1-SNAPSHOT on 2d22f135a7b8 with PID 1 (/app.jar started by root in /)
web_1    | 2020-03-16 12:02:05.907  INFO 1 --- [           main] com.example.demo.DemoApplication         : No active profile set, falling back to default profiles: default
web_1    | 2020-03-16 12:02:06.581  INFO 1 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode!
web_1    | 2020-03-16 12:02:06.584  INFO 1 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data repositories in DEFAULT mode.
web_1    | 2020-03-16 12:02:06.619  INFO 1 --- [           main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 18ms. Found 0 repository interfaces.
web_1    | 2020-03-16 12:02:07.163  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
web_1    | 2020-03-16 12:02:07.197  INFO 1 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
web_1    | 2020-03-16 12:02:07.198  INFO 1 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.26]
web_1    | 2020-03-16 12:02:07.292  INFO 1 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
web_1    | 2020-03-16 12:02:07.292  INFO 1 --- [           main] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1338 ms
web_1    | 2020-03-16 12:02:07.937  INFO 1 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
web_1    | 2020-03-16 12:02:08.230  INFO 1 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
web_1    | 2020-03-16 12:02:08.233  INFO 1 --- [           main] com.example.demo.DemoApplication         : Started DemoApplication in 2.696 seconds (JVM running for 3.19)

首先,创建了一个 项目名_default 的网络,然后开始构建 web 服务,构建完成后开始构建 redis 服务,但是发现并没有镜像,于是从远程拉取该镜像,最终,创建实例 demo_redis_1 和 demo_web_1,因为我没有配置要启动多个实例。打开浏览器访问:localhost:8080/count,页面如下,每次刷新会增长。
输入 Ctrl + C,停止该项目:

^CGracefully stopping... (press Ctrl+C again to force)
Stopping demo_redis_1 ... done
Stopping demo_web_1   ... done

接下来展示 docker-compose 中基本命令的结果:

[aspire@localhost demo]$ docker-compose psName                  Command                State     Ports
----------------------------------------------------------------
demo_redis_1   docker-entrypoint.sh redis ...   Exit 0
demo_web_1     java -jar /app.jar               Exit 143  [aspire@localhost demo]$ docker-compose restart
Restarting demo_redis_1 ... done
Restarting demo_web_1   ... done
-- 注意,这里重启项目后,再次访问 /count ,值变为3,并没有从1开始[aspire@localhost demo]$ docker-compose imagesContainer     Repository    Tag       Image Id       Size
------------------------------------------------------------
demo_redis_1   redis        alpine   d8415a415147   30.38 MB
demo_web_1     demo_web     latest   e0810f610c02   170.1 MB[aspire@localhost demo]$ docker-compose port web 8080
0.0.0.0:8080[aspire@localhost demo]$ docker-compose stop
Stopping demo_redis_1 ... done
Stopping demo_web_1   ... done[aspire@localhost demo]$ docker-compose rm
Going to remove demo_redis_1, demo_web_1
Are you sure? [yN] y
Removing demo_redis_1 ... done
Removing demo_web_1   ... done

未解决的问题

  1. 第四章 Docker 容器部分,通过 docker create 创建的容器,然后以 docker start -ai 运行,不能进入终端而是直接退出。但是通过 docker run 创建的容器,再以 docker start -ai 启动,可以连接终端。

参考资料

[1] Docker中文教程
[2] 知乎:如何通俗解释Docker是什么?
[3] Docker官方文档
[4] Docker从入门到实践
[5] 阿里巴巴开源镜像站
[6] 面试官:你简历中写用过docker,能说说容器和镜像的区别吗?
[7] Dockerfile 最佳实践文档
[8] 镜像中心 - 网易云镜像中心

6w字教程入门Docker相关推荐

  1. Docker教程(一) Docker入门教程

    Docker教程(一) Docker入门教程 本文链接:https://blog.csdn.net/yuan_xw/article/details/51935278 Docker教程(一) Docke ...

  2. Docker 入门教程(一) - Docker Tutorial

    Docker 教程 作者: Jakob Jenkov 原文链接 Docker是一种使用名为Dockerfile的打包规范将应用程序和服务器配置打包为 Docker 镜像的简单方法. Docker 镜像 ...

  3. 新手零基础快速入门Docker

    Docker学习 前言 今天第一次学习docker,跟着一位up主的视频进行了练习,把一些视频中讲到的内容记录了下来,并结合菜鸟教程中docker教程写下本文. 本文是我零基础入门docker的第一篇 ...

  4. 光速入门Docker 和 Kubernetes,一起学~

    只需要每天晚上花三两个小时,在一周业余的时间里,你就能快速入门Docker和Kubernetes!! 第一期云原生在线技术工坊已经圆满结束,好评如潮,下面是部分参与者打卡截图: 第二期技术工坊活动再度 ...

  5. 【python教程入门学习】Python实现自动玩贪吃蛇程序

    这篇文章主要介绍了通过Python实现的简易的自动玩贪吃蛇游戏的小程序,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学一学 实现效果 先看看效果 这比我手动的快多了,而且是单机的,自动玩没惹 ...

  6. 【helloworld】-微信小程序开发教程-入门篇【1】

    1. 开篇导言 本节目标:旨在演示如何用开发者工具构建并运行简单的 helloworld 应用. 目标用户:无编程经验,但对微信小程序感兴趣的同学. 学习目标:开发者工具的基本使用流程,即创建.导入. ...

  7. wxpython使用实例_wxPython中文教程入门实例

    wxPython中文教程入门实例 wx.Window 是一个基类,许多构件从它继承.包括 wx.Frame 构件. 可以在所有的子类中使用 wx.Window 的方法. wxPython的几种方法: ...

  8. Vue-cli3配置教程入门

    Vue-cli3配置教程入门 vue-cli3推崇零配置,其图形化项目管理也很高大上. 但是vue-cli3推崇零配置的话,导致了跟之前vue-cli2的配置方式都不一样了. 别名设置,sourcem ...

  9. Docker教程(二) Docker环境安装

    Docker教程(二) Docker环境安装 本文链接:https://blog.csdn.net/yuan_xw/article/details/77248243 Docker教程(二) Docke ...

最新文章

  1. 写给小白看的线程和进程,高手勿入
  2. JS模块化写法(转)
  3. 基于js对象,操作属性、方法详解
  4. webstorm 激活方法
  5. 【数据结构与算法】之深入解析“分数到小数”的求解思路与算法示例
  6. CDN调试—Debug Headers
  7. map unordered_map hash_map的查找性能测试
  8. 使用Nginx+WordPress搭建个人网站
  9. linux批量替换文件夹中所有文件内容
  10. linux实现字符火焰动画,linux flamegraph火焰图使用
  11. 没毛病!00后和90后成为暑期出游两大主力群体
  12. Git Windows下安装配置
  13. 中文文档列表 - Oracle Database (文档 ID 1533057.1)
  14. Lottie动画测试工具
  15. 大数据技术与人工智能的关系
  16. 第5章-构建Spring Web应用程序
  17. 信用社pb通用记账_信用社会计记账采用的是()。A、收付实现制B、权责发生制C、借贷记账法D、单式记账法...
  18. 电流测试c语言算法,电流检测电路设计方案汇总(六款模拟电路设计原理图详解)...
  19. tomcat日志格式转化为json
  20. 更新wlan.bin文件

热门文章

  1. 要学习的内容 (一)
  2. linux dev shm 的大小,使用linux的/dev/shm增强性能
  3. 多种类型的导航条制作【css3,jquery】
  4. Cocos2d-x 2.0 按键加速处理深入分析
  5. 微信,支付宝收款免签APP源码
  6. [已解决]java.time.temporal.UnsupportedTemporalTypeException: Unsupported field: HourOfDay
  7. 十二生肖查询器php,十二生肖查询网页版制作(php)
  8. Java使用HttpClient模拟登录微博
  9. delphi xe5 安装 fastreport5
  10. Pixel-安卓系统刷机指南