6
Docker 是一个能让程序跑在一个它无法感知的、用于隔绝外界环境里的容器的工具。

Docker 简介

最初是 dotCloud 公司创始人 Solomon Hykes 发起的一个公司内部项目,并于 2013 年 3 月以 Apache 2.0 授权协议开源,代码主要在 GitHub 上进行维护。Docker 项目后来还加入了 Linux 基金会,并成立推动 开放容器联盟(OCI)

Docker 使用 Google 推出的 Go 语言开发实现,基于 Linux 内核的 cgroupnamespace,以及 UnionFS 等技术。最初实现基于 LXC,从 0.7 版本后去除 LXC,转而开始使用自行开发的 libcontainer,从 1.11 开始,则进一步演进为使用 runCcontainerd

在 2017 年 4 月 21 日 Pull Request #32691 将原有的 Docker 项目更名为 Moby,由 Moby 构建出 Docker CE(社区版),而新的 Docker 项目则构建出 Docker EE(企业版本)。

Docker 初步了解

Docker 容器与虚拟机的区别

原理不同

F932548E-8278-415E-B844-DD50B33500A8.png

上图是 Docker Doc 关于 Docker 和传统虚拟机区别的截图。

Docker 利用了 Linux 内核的 cgroup 和 namespace 为程序的执行创造一个隔离的环境,使得程序感知不到外界的存在,其本身仍然是跑在原有的内核上的;而虚拟机则是通过 Hypervisor 模拟了一整套系统环境,虚拟机里的程序是跑在虚拟机内核上的。由于虚拟机需要模拟一整套操作系统环境,因此开销比 Docker 容器要高很多很多。

你可以把跑在容器里的程序想象成楚门(楚门的世界男主),他并不知道自己生活在一个精心布置的超大影棚里,但是他仍然是活在现实世界里的,呼吸着现实世界中的空气,吃着和我们差不多的食物;跑在虚拟机里的程序就好像活在动画片里的小猪佩奇,他的一切都是虚拟的,虽然小猪佩奇并不知道自己活在动画片里,但是很显然的是它和我们完全不在一个世界(不是同一个系统内核)。

性能差异

特性 容器 虚拟机
启动 秒级 分钟级
硬盘使用 一般为 MB 一般为 GB
性能 接近原生 弱于
系统支持量 单机上千个容器 一般几十个

Docker 基本概念

镜像

镜像 是一个包含操作系统完整 root 文件系统 的、只读的,由多层文件系统联合而成的打包文件。

Docker 为了让应用无感知的跑在容器中,提供了一套完整的 root 文件系统,比如官方镜像 library/ubuntu 就包含了一整套 root 文件系统。像 apache、nginx 都是基于该镜像构建的,由于 library/ubuntu 本身很大,所以 Docker 采用了分层存储的方式。

F8A2BBF4-F35D-4AC5-A897-8CDC0349E945.png

本文假装你已经安装了 Docker,上图通过 docker pull nginx官方 Registry(下面会提到这是啥)拉取 nginx 镜像,拉取 nginx 相当于 library/nginx:latest,library 表示 nginx 是官方镜像,因此可以省略,:latest 表示拉取标签为 latest 的镜像。拉取后可以看到存在两个镜像,因为 nginx 镜像本身就是基于 library:ubuntu:16.04 镜像的。

3BFBA27D-6B51-445C-8D02-00F02A9A747A.png

上图通过 docker pull httpd 拉取了 apache 镜像,由于 ubuntu:16.04 镜像已经在本地存在了,因此拉取的时候不会重复拉取。从而节约拉取时间。这就是 Docker 分层存储的意义。

镜像的只读可以理解成以前的光盘 CD,是不可更改的。为了模拟实现对光盘 CD 的写的功能,会建立两层文件系统,一层是光盘 CD 的只读文件系统;另外一层是存放更改数据的可写的文件系统。从而实现模拟更改镜像的作用。Docker 也是采用这种类似的分层的方式。

018E0260-D613-4098-A1E0-D0E6EC133014.png

如图,可以看出 ubuntu:15.04 是由很多层文件系统(镜像)堆叠形成的,最底层是 root 文件系统(d3a1f33e8a5a)。这几层文件系统都被设置成只读的。多层文件系统利用了上面提到的 UnionFS、AUFS、OverlayFS,这是一类文件系统,这种联合挂载文件系统最早就是用于解决 CD 这种只读文件系统的修改问题,Docker 之前使用 AUFS,但是由于 AUFS 不被 linus 喜欢(被 linus 评价为稠密、不可读,无注释)导致 AUFS 一直没有被合并到 Linux 的主分支中。Docker 在 1.12 以后已经将默认的文件系统从 AUFS 替换成 OverlayFS2。因为 OverlayFS2 已经被合并进了 Linux 的主干分支中。

容器

上面我们拉取了 nginx 镜像到本地,我们可以使用 docker container start nginx(省略了 latest 标签)来运行这个镜像。运行之前会先创建一个容器(其实本质就是创建了一层可读写的文件系统,以提供程序运行时的读写支持),然后就会启动程序,让程序跑在一个隔离环境(不是虚拟环境)里。你还可以通过 docker container commit>来对当前层进行提交(就好像 Git 提交一样),从而形成一个新的镜像,但是这种方式是不推荐的;这是因为在程序运行过程中可能会产生一些垃圾文件,而如果这些垃圾文件被提交后,新的镜像又是不可修改的,只会增大镜像的体积。具体怎么创建镜像会在下面说到。

container-layers.jpg

可以看到上图中在创建容器的时候其实就是创建了一个容器可读写层。你还可以通过 docker container stop<container ID> 停止容器的运行,相当于 kill 掉容器内的正在运行的程序,但是创建容器时创建的可读写的文件系统依然存在。所以你依然可以通过 docker start <container ID> 来重启程序。

仓库

镜像构建完成后,可以很容易的在宿主机器上运行,但是如果其他机器要使用这个镜像,我们就需要一个集中存储、分发镜像的服务,Docker Registry 就是这样的服务。一个 Docker Registry 可以包含多个仓库,每个仓库可以包含多个标签,每个标签对应一个镜像。

就拿上面的 library/nginx:latest 举例,library 表示这个镜像是官方镜像,如果不是官方镜像,这里一般填注册在 Docker Registry 的用户名;library/nginx 是仓库的名字,latest 是该仓库一个标签。

诚然,官方的 Docker Registry 是世界上最大的镜像分发服务,官方还提供了 Docker Registry 镜像 用于搭建私有镜像分发服务。而且 DockerHub 和社区一起制作了大量的、高质量的镜像,使得我们构建镜像更为方便。

Docker 简单实践

Docker 单个镜像

前面提到可以通过 docker commit 生成新的镜像,但是这种方式并不推荐(原因已经说明),所以我们一般还是采用 Dockerfile 的方式。下面的实践以 github-issue-rss 为例,demonstate how to containerization a normal project。

首先创建一个 Dockerfile 文件,内容如下:

FROM node:9-alpine

MAINTAINER mrcode "mrcodehang@outlook.com"

WORKDIR /src # 表示容器内的程序运行时的当前目录

COPY . /src # 把构建 Dockerfile 文件目录下的文件全部复制到镜像的 /src 目录下
RUN npm install -g yarn && yarn install # 构建时执行

EXPOSE 3000 # 暴露容器的 3000 端口到外面

ENTRYPOINT ["npm", "start"] # 执行 docker start <container ID> 时就会执行 npm start

Dockerfile 里的每一行开头的大写字母单词叫做 Dockerfile 指令。每执行一条指令就会增加一层镜像(本质是执行了一次 docker commit,而 AUFS 最大的层数是 127 层,因此 Dockerfile 里的层数最好不要太多!

FROM 表示基于哪一个镜像构建,node:9-alpine 表示基于官方的 node 镜像构建,标签 9-alpine 表示这是一个 node 9 的镜像,同时该 node9 镜像是基于 alpine 镜像构建的,alpine 是 Linux 的一个精简发行版,大小只有 5MB 左右,而 Ubuntu 镜像大小接近 200MB。

RUN 指令会在构建镜像时执行,使用 && 符号是为了减少 RUN 命令的使用次数,减少最终镜像的层数。

EXPOSE 指令让外界能通过容器的 3000 端口进行网络通信。

ENTRYPOINT 表示执行 docker start <container ID> 时就会执行 npm start(启动程序);还可以写成 ENTRYPOINT npm start 这种形式;然后就可以开始构建了。有的同学喜欢在 npm start 后加上 '&',来让容器默认后台运行;但这只会导致容器无法启动,因为容器本身的执行完全是依靠程序本身的进程的,当程序本身进程没有挂载在 docker 容器上时,容器就会直接结束,容器结束后容器内的进程也被杀掉。所以要知道保持容器运行的正是容器内的进程本身!

39DC3EC7-D45C-468E-88C6-FD708197F228.png

图中执行命令最后有一个 '.',这是将当前目录作为上下文传递给 Docker daemon;Docker 的工作方式是基于 C-S 架构的,你需要将构建的所在目录传给 docker daemon,这也是上面的 Dockerfile 文件的 COPY 指令的当前目录

接下来创建容器,一个镜像可以创建多个容器(其实就是创建多个在同一层的读写层)。

314CECA0-D9C9-40D7-AFC7-FB8C43850A26.png

docker run 会拉取远程的镜像(如果本地没有的话),接着它会创建一个容器,基于 mrcode/github-issue-rss:test 镜像(只有 latest 标签可以省略);-v 会创建一个数据卷(volume),表示当容器对 /var/log/github-issue-rss/ 写入数据时相当于写在了宿主机的 ~/github-issue-rss/log 目录上,从而维持容器的无状态特性(无状态特性是指容器在运行时尽量不要将重要数据存储在容器所在的读写层里,虽然那是一层读写层,但是是用来存放程序运行时产生的临时文件的,不应将重要数据放在里面);-d 表示 daemon 执行程序,否则的话容器进程会挂载在当前 shell 上,一般通过 -d 挂载到 docker daemon 进程上;—rm 表示容器退出后自动删除容器,这是推荐的用法,也是容器的无状态特性的体现。

容器进程具有和容器内程序本身进程相同的生命周期,容器进程用来启动容器内程序,相当于 Linux 内的 init 进程;当容器内程序被 docker stop <container ID> 杀掉时,容器就会退出,留下一个已创建的读写层文件系统,这也是容器存在的标志。

由于创建容器仅仅是创建了一个可读写的文件系统,所以容器的存在是非常非常轻量级的。即便对一个镜像创建多个容器,镜像本身是不会被重新拷贝的,而是最大程度的复用,这是因为镜像内的多层文件系统的每一层都被设置成只读的。

你可以通过 docker container ls 查看当前正在运行的所有容器,如果还想查看已退出的容器,加上一个 -a 参数。使用 docker container start/stop 可以启动/关闭容器。

最后可以通过 docker push mrcode/github-issue-rss:test 发布到 DockerHub 上,分享到社区。

Docker 多个镜像

github-issue-rssis a tool converts the issues on GitHub to RSS.

这个工具需要用到了 mysql,为了以后方便数据迁移,我决定使用 mysql 镜像,mysql 镜像可以把所有状态存放在宿主机的一个文件夹下。那我现在不仅需要启动 mysql 和 github-issue-rss 镜像,还需要建立他们之间的网络连接关系,事情变得麻烦了。有一个工具叫 docker-compose (本文假装你已经安装了这个工具)可以把这一切自动化。下面是项目根目录的一个 docker-compose.yml 文件:

version: "3"
services:
    db:
        image: mysql:5.7
        volumes:
            - ~/.github-issue-rss/mysql:/var/lib/mysql
        restart: always
        environment:
            MYSQL_ROOT_PASSWORD: rootroot
            MYSQL_DATABASE: rss
            MYSQL_USER: mrcode
            MYSQL_PASSWORD: github-issue-rss
    github-issue-rss:
        image: mrcode/github-issue-rss:v0.1.0
        depends_on:
            - db
        ports:
            - "3000:3000"
        restart: always
        environment:
            MYSQL_PORT: 3306
            MYSQL_HOST: db
            MYSQL_SCHEMA: rss
            MYSQL_USERNAME: mrcode
            MYSQL_PASSWORD: github-issue-rss
            LOG_FILE: /var/log/github-issue-rss/
        volumes:
            - ~/.github-issue-rss/log/:/var/log/github-issue-rss/

在 docker-compose 的世界里没有容器,只有服务。它认为它启动了两服务 db 和 github-issue-rss。没有哪个是主服务,所有服务都是平等的。

在 db service 中,设置了 volumes,将 mysql 的数据存储在 ~/.github-issue-rss/mysql/ 里,还可以设置更多的 volume。restart 表示只要服务执行失败就重启,防止依赖的 service 还没有启动完成时导致的错误引发连锁反应。给两个 service 配置的 environment 来建立两者的数据连接,github-issue-rss 代码会读取这个环境变量,然后连接到 db 服务,可以看到 github-issue-rss 里的环境变量 MYSQL_HOST 设置为 db,这是因为 docker-compose 会在启动的服务配置里建立这个 DNS 映射关系。

还可以通过 docker-compose down 来停止并且删除服务对应的容器。

现在你只需要克隆仓库到本地,然后执行 docker-compose up 就可以启动 github-issue-rss 了,因为 github-issue-rss 镜像本身已经构建并发布到 Docker Hub 了。

Docker 的应用

持续集成和持续交付

使用 Docker 可以通过定制应用镜像来实现持续集成、持续交付、部署。开发人员通过 Dockerfile 进行镜像构建,结合持续集成系统进行集成测试,而运维人员则可以在生产环境中快速部署该镜像。甚至结合持续部署进行自动部署。

而且使用 Dockerfile 使镜像的构建透明化,不仅可以帮助开发人员理解应用运行环境,也方便运维团队理解应用运行所需条件,帮助更好的生产环境中部署该镜像。

微服务

Docker 和微服务架构简直就是浑然天成,站在 Docker 的角度,软件本质是容器的组合:业务逻辑容器、数据库容器、存储容器、队列容器……Docker 使得软件拆分成若干的标准化容器,然后像积木一样的搭建起来。这正是微服务的思想:软件把任务外包出去,让各种外部服务完成这些任务,软件本身只是底层服务的调用中心和组装层。

参考文章


mrcode
775 声望43 粉丝

BUG Maker & Barcelona fan & Front-end developer at Bytedance Inc & Graduated in ChongQing university of Technology.