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 设备和必要驱动库暴露给容器。
总而言之,宿主机的 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 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 指令
删除镜像
如果要移除本地的镜像,可以使用 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。
参考文章
[1] Docker 是什么
[2] Docker 基础知识
[3] Create Your Own Docker Image
[4] 获取镜像
[5] Dockerfile 指令详解
评论