Docker 基础知识

Docker 的优势在于一次创建或配置,便可在任意地方正常运行。使用一个标准镜像构建一套开发容器,在开发完成后,其他人便可使用这个镜像或容器环境来部署代码。

Docker

相比传统虚拟机(VM)在硬件层面实现虚拟化,Docker 更接近操作系统层面的进程隔离。它通过宿主机内核提供的 namespace、cgroups、UnionFS 等机制隔离进程、限制资源并组织文件系统。Docker 不是直接复制 localhost 的操作系统,而是在共享宿主机内核的前提下,为应用提供相对独立的运行环境。

Docker 三大组件

镜像

Docker 镜像是一个只读模板,镜像可以用来创建 Docker 容器。

容器

容器是从镜像创建的运行实例,Docker 利用容器来运行应用。每个容器之间相互隔离。虽然镜像是只读的,但是容器启动时会在镜像层上方创建一层可写层,作为容器运行期间的文件系统变化层。

仓库

仓库是集中存放镜像文件的场所,概念上与 Git 仓库类似。除了网络上的公开仓库,用户也可以在本地网络内创建私有仓库。

用户可以使用 push 将自己的镜像上传到仓库,也可以使用 pull 指令从仓库下载镜像。

Docker 镜像

Docker 运行容器前,需要本地存在对应镜像。如果镜像本地不存在,Docker 会从镜像仓库下载。

获取镜像

使用 docker pull 命令可以从仓库拉取所需镜像:

docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签]
  • Docker 镜像仓库地址:地址格式一般是 <域名/IP>[:端口号]。默认地址是 docker.io。
  • 仓库名:这里的仓库名通常是两段式名称,即 <用户名>/<软件名>。对于 Docker Hub,如果不给出用户名,则默认为 library,也就是官方镜像。

比如可以用下面的指令从 Docker Hub 拉取一个 Ubuntu 操作系统镜像:

# 因为我的配置问题,我的 docker 相关命令必须使用 sudo 获取 root 权限才可以运行
$ sudo docker pull ubuntu:20.04
20.04: Pulling from library/ubuntu
915eebb74587: Pull complete
Digest: sha256:ed4a42283d9943135ed87d4ee34e542f7f5ad9ecf2f244870e23122f703f91c2
Status: Downloaded newer image for ubuntu:20.04
docker.io/library/ubuntu:20.04

上面的命令中没有给出 Docker 镜像仓库地址,因此会从 Docker Hub 获取镜像。镜像名称是 ubuntu:20.04,因此会获取官方镜像 library/ubuntu 仓库中标签为20.04 的镜像。

docker pull命令输出结果最后一行给出了镜像完整名称,即docker.io/library/ubuntu:20.04

列出镜像

可以使用 docker image ls 命令列出已经下载完成的镜像。

$ docker image ls
REPOSITORY           TAG                 IMAGE ID            CREATED             SIZE
<none>               <none>              00285df0df87        5 days ago          342 MB
ubuntu               20.04               0341906bdafc        4 weeks ago          65.7MB

列表中包含:

  • 仓库名:来自哪个仓库,例如 ubuntu。
  • 标签:例如 20.04。
  • ID 号:镜像唯一标识。
  • 创建时间。
  • 所占空间:这里显示的空间和 Docker Hub 上看到的镜像大小不同,是因为 Docker Hub 中显示的体积通常是压缩后的体积。

创建镜像

关于 CUDA 的兼容性问题

相比 CPU,显卡是一种比较特殊的硬件设备,因为它有专门的驱动。正常来讲,Docker 容器内的 CUDA runtime / toolkit 和物理机不是同一个东西,但 GPU、CUDA、驱动和容器运行时之间的对应关系比较复杂,所以我对 Docker 和 GPU 硬件之间的兼容性打了个问号。于是 Google 了相关问题,看到这篇文章:CUDA 兼容性问题(显卡驱动、docker 内 CUDA)

根据官方文档的描述:

Each release of the CUDA Toolkit requires a minimum version of the CUDA driver. The CUDA driver is backward compatible, meaning that applications compiled against a particular version of the CUDA will continue to work on subsequent (later) driver releases.

也就是说,每个 CUDA Toolkit 版本都要求宿主机上的 NVIDIA 驱动至少达到某个最低版本。NVIDIA 驱动通常向后兼容旧版 CUDA runtime,因此用某个 CUDA 版本编译或运行的程序,通常可以在更新的驱动版本上运行。但容器内 CUDA Toolkit / runtime 仍然要求宿主机驱动满足最低版本要求。

先区分几个概念:

  • CUDA Toolkit:库文件、运行时和开发工具,可以理解为面向开发者的 CUDA 编译环境。
  • CUDA Driver:用户态 CUDA 驱动组件,负责让应用与内核态 NVIDIA 驱动交互。
  • NVIDIA GPU 驱动:宿主机上的核心硬件驱动,真正负责管理 GPU 硬件。

顺便一提,CUDA 版本遵循 X.Y.Z 的命名方式:

  • X:大版本,可能改变 API,不保证二进制兼容性和编译环境兼容性。
  • Y:可能增删新函数和 API,通常保证二进制兼容性,但不一定保证编译环境完全兼容。
  • Z:补丁版本,通常保证编译环境和二进制兼容性。

官方还提到:

Using a Minimum Required Version that is different from Toolkit Driver Version could be allowed in compatibility mode -- please read the CUDA Compatibility Guide for details

所谓兼容模式,重点解决的是运行环境兼容问题。Docker 是轻量级容器,不同于 VM 虚拟机。CUDA 镜像可以包含 CUDA runtime / toolkit 等用户态库,但不包含宿主机内核态 NVIDIA GPU 驱动。容器要使用 GPU,需要依赖宿主机驱动,并通过 nvidia-container-toolkit 将 GPU 设备和必要驱动库暴露给容器。

CUDA components

总而言之,宿主机的 NVIDIA GPU 驱动版本必须满足容器内 CUDA Toolkit / runtime 要求的最低驱动版本。否则,即使容器里安装了 CUDA,也可能无法正常编译或运行 CUDA 程序。宿主机 NVIDIA 驱动选择较高版本,通常更容易兼容不同容器中的 CUDA 版本。下面是例子:

宿主机 GPU 驱动 Docker 内 CUDA 版本 兼容性 备注
455.23 11.1 兼容
455.23 11.2 不兼容 11.2 >= 460.27.03
455.23 11.3 不兼容 11.3 >= 460.19.01
470.94 11.1 兼容
470.94 11.2.x 兼容
470.94 11.3.x 兼容
470.94 11.5.x 不兼容 11.5 >= 495.29.05

因此,即使 Docker 容器内 CUDA runtime / toolkit 和主机文件系统相互隔离,Docker 仍然和宿主机 NVIDIA 驱动有关。为了满足 CUDA Toolkit 的最低驱动要求,宿主机可能需要升级 NVIDIA GPU 驱动。

CUDA components 2

从上图也可以看出,容器中通常只包含 CUDA runtime / toolkit 等用户态组件,而不包含宿主机内核态 NVIDIA GPU 驱动。

修改已有的镜像

不要使用 docker commit 作为主要的定制镜像方式,定制镜像更推荐使用 Dockerfile。大部分情况下我们使用的是来自 Docker Hub 的镜像,但有时这些镜像不能满足使用要求,因此需要定制镜像。

镜像是多层存储,每层都在上一层基础上进行修改。容器也是多层存储,会以镜像为基础层,并在其上添加一层容器运行时的可写层。Docker 的 docker commit 命令可以将容器的可写层保存下来成为镜像。换句话说,就是在原有镜像基础上再叠加容器存储层,构成新的镜像。以后运行这个新镜像时,就会拥有原有容器最后的文件变化。

docker commit 语法格式为:

sudo docker commit [选项] <容器ID或容器名> [<仓库名>[:<标签>]]

虽然使用 commit 命令可以直观理解镜像分层概念,但生产环境中一般不会这样使用。如果使用这个命令安装软件包或编译构建,除当前层外,之前的层不会发生改变,任何修改都是在当前层上进行标记、添加、修改。这样会把大量无关内容添加进来,导致镜像臃肿。

此外,commit 对镜像的操作是黑箱操作,生成的镜像被称为黑箱镜像,维护非常困难。

使用 Dockerfile 创建镜像

除了上面提到的 commit 方法来扩展现有镜像,还可以使用 docker build 方法来创建一个新的镜像。首先需要创建一个 Dockerfile,其中包含创建镜像的指令。

Dockerfile 基本语法包括:

# 注释
FROM <基础镜像>
LABEL maintainer="维护者信息"
RUN <命令>

FROM 告诉 Docker 以哪个镜像作为基础镜像。LABEL maintainer="..." 可以记录维护者信息。

旧版 Dockerfile 中常见 MAINTAINER,但现在已经不推荐,通常使用 LABEL 代替。RUN 指令会在镜像构建过程中执行命令。

FROM 指定基础镜像

虽然是自定义镜像,但是也必然会以某个镜像为基础。FROM 在 Dockerfile 中通常必须是第一条有效指令。Docker Hub 中提供了很多官方镜像,也有 ubuntu 之类的操作系统镜像。此外还有一种特殊镜像 scratch,它是一个虚拟概念,并不实际存在,表示一个空白镜像,即不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。

RUN 执行命令

RUN 命令有两种格式:

  • Shell 格式:RUN <命令>,与直接在 terminal 中输入命令类似。
  • exec 格式:RUN ["可执行文件", "参数1", "参数2"],类似函数调用。

每个 RUN 命令会新建一层,因此注意不要用下面的写法:

FROM debian:stretch

RUN 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

更合适的写法如下:

FROM debian:stretch

RUN set -x; 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

使用 && 将各个所需命令串联起来,可以将之前的 7 层简化为 1 层。这里为了格式化还进行了换行。Dockerfile 支持 shell 类的行尾 \ 命令换行方式,以及行首 # 注释格式。

还可以看到,这组命令最后添加了清理工作:删除为了编译构建所需要的软件,清理所有下载和展开的文件,并且清理 apt 缓存文件。这是很重要的一步。镜像是多层存储,每一层的东西不会因为下一层删除命令而从旧层中消失。因此镜像构建时,一定要确保每一层只添加真正需要的东西,任何无关的东西都应该清理掉

NOTE:旧版 AUFS 常见镜像层数限制是 127 层;现代 storage driver 的限制和行为可能不同,但仍然应该减少不必要的层数。

构建镜像

在 Dockerfile 所在目录执行:

$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM nginx
 ---> e43d811ce2f4
Step 2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
 ---> Running in 9cdc27646c7b
 ---> 44aa4490ce2c
Removing intermediate container 9cdc27646c7b
Successfully built 44aa4490ce2c

其中 -t 指令用来添加 tag,后面的 : 前后两部分是镜像名称 REPOSITORY:TAG。

镜像构建的上下文(Context)

注意最后的 . 不要遗漏。它并不简单等同于当前目录或 Dockerfile 所在目录,实际上是在指定上下文路径

Docker build 的工作原理:Docker 在运行时分为 Docker 引擎(也就是服务端守护进程)和客户端工具。Docker 引擎提供一组 REST API,被称为 Docker Remote API,而 docker 命令这样的客户端工具,是通过这组 API 与 Docker 引擎交互,从而完成各种功能。因此,虽然表面上好像是在本机执行各种 docker 功能,但实际上,很多操作都是以远程调用形式在服务端(Docker 引擎)完成。也因为这种 C/S 设计,操作远程服务器的 Docker 引擎会很方便。当进行镜像构建时,并非所有定制都会通过 RUN 指令完成,经常需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。而 docker build 命令构建镜像,其实是在 Docker 引擎中构建。那么在这种客户端/服务端架构中,如何才能让服务端获得本地文件呢?这就引入了上下文概念。构建时,用户会指定构建镜像上下文路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。Docker 引擎收到上下文包后,展开就能获得构建镜像所需的一切文件。

不理解上下文导致的问题

如果在 Dockerfile 中这么写:

COPY ./package.json /app/

这既不是复制执行 docker build 命令所在目录下的 json,也不是一定复制 Dockerfile 目录下的 json,而是在复制上下文目录下的 json。所以 COPY 这类指令中的源文件都是相对于构建上下文的路径。

因此,在 docker build 时末尾这个 . 实际上是在指定上下文目录,docker build 命令会将该目录下的内容打包交给 Docker 引擎以帮助构建镜像。如果把 Dockerfile 放到根目录下去构建,会发现它正在发送一个几十 G 的东西,因为此时正在将根目录下的所有内容打包。

其他 docker build` 方法

  • 用 Git repo 进行构建。
  • 用给定的 tar 压缩包构建。
  • 从标准输入中读取 Dockerfile 进行构建。
  • 从标准输入中读取上下文压缩包进行构建。

更多 `Dockerfile 指令

Dockerfile 指令详解

删除镜像

如果要移除本地的镜像,可以使用 docker rmi 命令。注意,docker rm 命令是移除容器

在删除镜像之前,要先用 docker rm 删除依赖于这个镜像的所有容器。

导出和导入镜像

如果要导出镜像到本地文件,可以使用 docker save 命令:

$ sudo docker save -o ubuntu_20.04.tar ubuntu:20.04

如果要把本地镜像文件导入到本地镜像库,可以使用 docker load。这会导入镜像以及相关元数据信息,包括镜像层、标签等。

$ sudo docker load --input ubuntu_20.04.tar

需要注意,docker save / docker load 面向的是镜像,保留镜像层、标签和历史等信息;docker export / docker import 面向的是容器文件系统快照,会丢失镜像历史、层信息和部分元数据。

Docker 容器

启动容器

启动容器有两种方式:一种是基于镜像新建一个容器并启动,另一种是将终止状态(Exited)的容器重新启动。因为 Docker 容器比较轻量,很多时候用户都是随时删除和新建容器。

新建并启动容器

命令为 docker run

$ docker run -t -i ubuntu:20.04 /bin/bash
root@cf4c7a2a2e47:/usr/local#

其中,-t 选项让 Docker 分配一个伪终端(pseudo-tty)并绑定到容器标准输入上,-i 则让容器标准输入保持打开。在交互模式下,用户可以通过所创建的终端输入命令。

当利用 docker run 创建容器时,Docker 在后台运行的标准操作包括:

  • 检查本地是否存在指定镜像,不存在就从 registry 下载。
  • 利用镜像创建并启动一个容器
  • 分配一个文件系统,并在只读镜像层外面挂载一层可读写层
  • 从宿主机配置的网桥接口中桥接一个虚拟接口到容器中。
  • 从地址池配置一个 IP 地址给容器。
  • 执行用户指定的应用程序。
  • 执行完毕后容器被终止。

启动已终止容器

可以利用 docker start 命令,直接将一个已经终止(Exited)的容器启动运行。

守护态运行

服务类容器常常会在后台以守护态(daemonized)形式运行。容器启动后会返回一个唯一 ID,也可以通过 docker ps 命令查看容器信息。交互式容器也很常见,并不是所有容器都必须后台运行。

守护态适合:

  • 长期运行。
  • 不需要交互式会话。
  • 运行应用程序和服务。

可以通过添加 -d 参数实现后台运行。此时容器会在后台运行,不会把输出结果(stdout)直接打印到宿主机终端上,输出结果可以用 docker logs 查看。

终止

可以使用 docker stop 终止一个运行中的容器,终止状态的容器可以用 docker ps -a 命令看到。

$ docker container ls -a
CONTAINER ID        IMAGE                    COMMAND                CREATED             STATUS                          PORTS               NAMES
cf4c7a2a2e47        ubuntu:20.04             "/bin/bash"            40 minutes ago      Exited (0) About a minute ago                       kind_hodgkin

处于终止状态的容器,可以通过 docker start 命令重新启动。

docker restart 命令会将一个运行态容器终止,然后再重新启动它。

进入容器

attach 命令

$ sudo docker run -idt dockertest:v0
b64c0db1a9263d239d72ff38b85b5ef7ffe6b1e4c0bdb7a91b80069e31313a12
$ sudo docker ps
CONTAINER ID   IMAGE           COMMAND                  CREATED         STATUS         PORTS     NAMES
b64c0db1a926   dockertest:v0   "/bin/sh -c /bin/bash"   8 seconds ago   Up 8 seconds   80/tcp    vigilant_poincare
cf4c7a2a2e47   dockertest:v0   "/bin/bash"              2 hours ago     Up 2 hours     80/tcp    kind_hodgkin
$ sudo docker attach vigilant_poincare
root@b64c0db1a926:/usr/local#

但是使用 attach 命令有时候并不方便。当多个窗口同时 attach 到同一个容器时,所有窗口都会同步显示。当某个窗口因命令阻塞时,其他窗口也无法执行操作。

如果从这个 stdin 输入 exit,会导致容器停止。

exec 命令

docker exec 后面可跟多个参数,这里主要说明 -i-t 参数。

只用 -i 参数时,由于没有分配伪终端,界面没有熟悉的 Linux 命令提示符,但命令执行结果仍然可以返回。

-i-t 参数一起使用时,则可以看到 Linux 命令提示符。

$ sudo docker run -idt dockertest:v0
a39344a590d28e46e5e5960856b120e83cc81d8c0003538b4ff402c91b408d4c

$ sudo docker container ls
CONTAINER ID   IMAGE           COMMAND                  CREATED          STATUS          PORTS     NAMES
a39344a590d2   dockertest:v0   "/bin/sh -c /bin/bash"   12 seconds ago   Up 10 seconds   80/tcp    inspiring_kalam
cf4c7a2a2e47   dockertest:v0   "/bin/bash"              2 hours ago      Up 2 hours      80/tcp    kind_hodgkin

$ sudo docker exec -i a393 bash
ls
bin
etc
games
include
lib
man
sbin
share
src
^C

$ sudo docker exec -it a393 bash
root@a39344a590d2:/usr/local#

如果从这个 stdin 中 exit,不会导致容器停止,因此推荐使用 exec 方法进入容器。

导入和导出

既可以使用 docker load 导入镜像存储文件到本地镜像库,也可以使用 docker import 将容器快照导入为本地镜像。

这两者的区别在于:容器快照文件会丢弃所有历史记录、镜像层和部分元数据信息,只保存容器当时的文件系统快照;而镜像存储文件会保留完整镜像记录,体积通常也更大。此外,从容器快照文件导入时可以重新指定标签等元数据信息。

导出容器

如果要导出本地某个容器,可以使用 docker export 命令,将容器文件系统快照导出到本地文件。

docker export a39344a590d2 > ubuntu.tar

会存储在当前执行命令的目录下,例如 home 目录。

导入容器

可以使用 docker import 从容器快照文件中再导入为镜像。

$ cat ubuntu.tar | docker import - test/ubuntu:v1.0
$ docker image ls
REPOSITORY          TAG                 IMAGE ID            CREATED              VIRTUAL SIZE
test/ubuntu         v1.0                9d37a6082e97        About a minute ago   171.3 MB

删除容器

可以使用 docker container rm 删除一个处于终止状态的容器。

清理所有处于终止状态的容器

docker container ls -a 命令可以查看所有已经创建的容器,包括终止状态的容器。如果数量太多,一个个删除会很麻烦,可以用下面的命令清理所有处于终止状态的容器:

$ docker container prune

仓库

一个容易混淆的概念是注册服务器(Registry)。虽然大部分时候不需要严格区分这几个概念,但理解它们有助于看懂镜像名称。

Registry 是镜像注册服务,可以理解为管理镜像仓库的服务器。Repository 是 Registry 中某个镜像集合,通常对应一个具体项目或目录。Tag 是某个 repository 下的具体版本标记。

例如对于仓库地址 dl.dockerpool.com/ubuntu 来说,dl.dockerpool.com 是 registry 地址,ubuntu 是 repository 名称。如果再加上 :20.04,则 20.04 是 tag。

Docker Hub 的使用


参考文章

[1] Docker 是什么

[2] Docker 基础知识

[3] Create Your Own Docker Image

[4] 获取镜像

[5] Dockerfile 指令详解