Intro

Docker 可以简化应用程序的配置、发布和测试,它彻底释放了计算虚拟化的威力,极大提高了应用的维护效率,降低了云计算应用开发的成本。

使用 Docker 的目的是解决因为开发环境不同而导致的程序开发或运行时会遇到的各种问题。举个例子说明,我们开发了一个应用程序,它在我们的电脑上可以正常运行,此时我们想把它分享给我们的朋友,那朋友不仅需要下载我们的代码,还需要配置相同的环境(插件、库等等)。即便如此,程序也不一定能正常运行,因为朋友使用的操作系统也可能与我们的不同,即便是同样的操作系统,也可能因为版本不同而导致程序无法正常运行。此时我们就需要 Docker 来帮助我们模拟完全相同的开发环境。

这里需要提到 Docker 和虚拟机的区别。它们都可以帮助我们模拟相同的开发环境,但是虚拟机需要先模拟硬件,然后在其上运行一个完整的操作系统,之后再在这个系统上运行所需的应用进程。这导致虚拟机不但体积臃肿、内存占用高,而且会影响程序的性能。Docker 在概念上与虚拟机类似,但比虚拟机更加轻便快捷。Docker 不会去模拟底层的硬件,它只会为每一个应用提供完全隔离的运行环境,我们可以在每个环境中配置不同的工具软件,而环境之间不会相互影响,这个环境在 Docker 中也被称作容器(container)。容器内的应用进程直接运行于宿主的内核中,容器内没有自己的内核,而且也没有进行硬件虚拟。因此容器要比传统虚拟机更为轻便。

基本概念

镜像(Image)

操作系统分为内核用户空间。对于 Linux 而言,内核启动后,会挂载 root 文件系统为其提供用户空间支持。而 Docker 镜像(Image),就相当于是一个 root 文件系统。

镜像类似于虚拟机的一个快照,它除了可以提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。镜像不包含任何动态数据,其内容在构建之后也不会被改变。

因为镜像包含操作系统完整的 root 文件系统,其体积往往是庞大的,因此在 Docker 设计时,就将其设计为分层存储的架构。镜像构建时,会一层层构建,前一层是后一层的基础。每一层构建完就不会再发生改变,后一层上的任何改变只发生在自己这一层。比如,删除前一层文件的操作,实际不是真的删除前一层的文件,而是仅在当前层标记为该文件已删除。在最终容器运行的时候,虽然不会看到这个文件,但是实际上该文件会一直跟随镜像。因此,在构建镜像的时候,需要额外小心,每一层尽量只包含该层需要添加的东西,任何额外的东西应该在该层构建结束前清理掉。

容器(Container)

通过镜像,我们可以创建许多个不同的容器。它们的关系就像是面向对象程序设计中的实例一样,镜像是静态的定义,容器是镜像运行时的实体。容器可以被创建、启动、停止、删除、暂停等。

每个容器就像是一台虚拟机。容器是独立运行的,互不影响。容器的实质是进程,但与直接在宿主执行的进程不同,容器进程运行于属于自己的独立的命名空间。因此容器可以拥有自己的 root 文件系统、自己的网络配置、自己的进程空间,甚至自己的用户 ID 空间。

与镜像一样,容器使用的也是分层存储。每一个容器运行时,是以镜像为基础层,在其上创建一个当前容器的存储层,我们可以称这个为容器运行时读写而准备的存储层为容器存储层。容器存储层的生存周期和容器一样,容器消亡时,容器存储层也随之消亡。因此,任何保存于容器存储层的信息都会随容器删除而丢失。

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

我们可以把数据卷当作是一个在本地主机和不同容器中共享的文件夹。数据卷的生存周期独立于容器,容器消亡,数据卷不会消亡。因此,使用数据卷后,容器删除或者重新运行之后,数据却不会丢失。

仓库(Repository)

如果我们想在远程服务器上使用镜像,就需要一个集中的存储、分发镜像的服务,Docker Registry 就是这样的服务。

一个 Docker Registry 中可以包含多个仓库(Repository);每个仓库可以包含多个标签(Tag);每个标签对应一个镜像。

通常,一个仓库会包含同一个软件不同版本的镜像,而标签就常用于对应该软件的各个版本。我们可以通过 <仓库名>:<标签> 的格式来指定具体是这个软件哪个版本的镜像。如果不给出标签,将以 latest 作为默认标签。

Ubuntu 镜像 为例,ubuntu 是仓库的名字,其内包含有不同的版本标签,如,16.04, 18.04。我们可以通过 ubuntu:16.04,或者 ubuntu:18.04 来具体指定所需哪个版本的镜像。如果忽略了标签,比如 ubuntu,那将视为 ubuntu:latest

仓库名经常以 两段式路径 形式出现,比如 jwilder/nginx-proxy,前者往往意味着 Docker Registry 多用户环境下的用户名,后者则往往是对应的软件名。但这并非绝对,取决于所使用的具体 Docker Registry 的软件或服务。

Dockerfile

Dockerfile 就像是一个自动化脚本,主要被用来创建镜像。

实践——用 Docker 部署一个应用程序

假设我们有一个应用程序,接下来将利用 Dockers 来部署它。

Dockerfile 文件创建

首先需要在这个应用的根目录下创建一个 Dockerfile 文件(本文中,Dockerfile 表示专有名词,Dockerfile 指的是具体的文件),并添加以下内容:

1
2
3
4
5
FROM python:3.8-slim-buster
WORKDIR /app
COPY . .
RUN pip3 install -r requirements.txt
CMD ["python3", "app.py"]

第一行我们用 FROM 命令指定一个基础镜像(base image),这样可以帮我们节省许多软件安装配置的时间。我们可以在 Docker Hub 上面找到许多高质量的操作系统镜像,以及一些为了方便使用某种语言或某种框架而开发的框架。

第二行的 WORKDIR 命令指定了之后所有 Docker 命令的工作路径(working directory)。如果指定的路径不存在,Docker 会自动创建。这样可以避免使用绝对路径或手动使用 cd 命令切换路径,增加程序的可读性。

之后利用 COPY 命令将所有的程序拷贝到 Docker 镜像中。完整的命令格式为 COPY <本地路径> <目标路径>。在本地路径中,. 表示程序根目录下的所有文件。在目标路径中,. 表示当前的工作路径,也就是上一行命令中指定的 app 目录。

随后的 RUN 命令允许我们在创建镜像时运行任意的 shell 命令。因为例子中使用的是 Linux 镜像,因此对于类似 echopwdcprm 等命令都是可以使用的。例子中我们使用 pip install 命令来安装 Python 程序的所有关联。关于该命令的详细介绍请看这里

通过上面的命令,就可以完成一个 Docker 镜像的创建。

Dockerfile 的最后,我们会使用 CMD 命令来指定在 Docker 容器运行起来后要执行的命令。这里需要注意前面提到过的镜像和容器的区别,同时也就能区分 RUNCMD 的区别。关于该命令的详细介绍请看这里

创建镜像

在完成 Dockerfile 文件后,我们可以使用 docker build 来创建镜像。

在程序根目录下进入终端,然后使用以下命令:

1
docker build -t my-app .

这里 -t 指定了镜像的名字,后面的 . 表示应该在当前目录下寻找对应的 Dockerfile。

第一次调用 docker build 会比较慢,因为 Docker 需要下载必要的镜像文件,然后一行行运行 RUN 指定的指令。但因为镜像的分层存储架构,后面的调用会快很多。

启动容器

镜像创建完成后,可以通过 docker run 命令来启动容器。

在程序根目录下进入终端,然后使用以下命令:

1
docker run -p 80:5000 -d my-app

这里 -p 会把容器上的某一个端口映射到本地主机上,这样才能够从主机上访问容器中的应用。这里前面的 80 是我们本地主机上的端口,后面的 5000 是容器上的端口。

第二个参数 -d 或者 --detached 可以让应用在后台运行,这样容器的输出就不会直接显示在控制台上。

创建并挂载数据卷

我们可以通过 docker volume create 来创建一个数据卷,随后在启动容器的时候,通过 -v 参数来把数据卷挂载到容器的指定路径上。

具体示例如下所示:

1
2
docker volume create my-app-data
docker run -dp 80:5000 -v my-app-data:/etc/data my-app

这里我们把 my-app-data 挂载到了 /etc/data 路径下,向这个路径写入的任何数据都会被永久保存在这个数据卷中。

常用命令

下面是操作容器时常用到的命令:

列举运行中的容器 docker ps
停止容器 docker stop <容器 ID>
重启容器 docker restart <容器 ID>
删除容器 docker rm <容器 ID>
启动一个远程 Shell docker exec -it <容器 ID> /bin/bash

关于远程 Shell 的使用可以参考这里

多个容器协作

在实际的应用中,我们可能会使用到多个容器协同工作。比如我们可以使用一个容器来运行 Web 应用,然后使用另一个容器来运行数据库系统。这样可以实现数据和应用逻辑的分离,比如当 Web 程序宕机时,数据库系统仍然可以正常工作,此时我们只需要修复 Web 容器即可。

为了实现上面的操作,我们需要使用 Docker Compose。

首先在程序根目录下创建一个 docker-compose.yml 文件,然后在文件中创建以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
version: "3"

services:
web:
build: .
ports:
- "80:5000"
db:
image: "mysql"
environment:
MYSQL_DATABASE: my-app-db
MYSQL_ROOT_PASSWORD: secret
volumes:
- my-app-data:/var/lib/mysql

volumes:
my-app-data:

在这个文件中我们可以通过 services 来定义多个容器。比如上面的代码中我们先定义了一个 web 容器用来运行我们的 Web 应用,然后定义了一个 db 容器用来运行数据库系统。

定义 db 的时候我们通过两个环境变量 MYSQL_DATABASEMYSQL_ROOT_PASSWORD 分别指定数据库的名字和连接密码。之后我们还可以通过 volumes 来指定一个数据卷用来永久存放数据。

定义完成后,我们通过根目录进入终端,并通过 docker compose up 来运行所有的容器:

1
docker compose up -d

这里的 -d 同样代表在后台运行所有的容器。

与之对应的,我们可以通过 docker compose down 来停止并删除所有的容器,但是数据卷需要我们手动删除,除非我们使用 docker compose down --volumes 命令。

Reference

  1. Docker — 从入门到实践 (gitbook.io)
  2. Docker 10分钟快速入门
  3. Docker Docs: How to build, share, and run applications | Docker Documentation