简单学习一下 Docker。

基本概念

Docker 是一种轻量级的容器化技术,与传统虚拟机相比,它更加高效,能够快速部署和运行应用程序。传统虚拟机需要完整的操作系统,每个虚拟机通常占用多个 GB,而 Docker 容器共享宿主机内核,镜像通常只有 MB 级别,占用更少的资源,启动速度更快。

首先需要区分 Docker 的两个核心概念:

  • Docker Image(镜像):Docker 镜像是一个只读的模板(实质是宿主系统中的一个文件),包含了运行应用程序所需的所有文件和环境(包括代码、运行时、依赖项、系统工具等)。镜像可以被用来创建容器,基于一个镜像可以创建多个容器。
  • Docker Container(容器):容器是基于镜像创建的运行实例(实质是宿主系统中的一个进程),它提供了一个隔离的轻量级 Linux 环境,可以在其中独立运行应用程序。 容器可以被创建、启动、暂停、停止、删除等,多个容器可以在同一个主机上运行,并且它们彼此之间是相互隔离的。容器在运行过程中产生的更改通常不会影响外部系统(除非使用专门的方式,包括数据卷和绑定宿主目录等),更不会影响镜像文件。

镜像的构造过程实际上是分层进行的,每一层都是将前一层视作只读基础来进一步构建的,这样可以最大程度地实现复用,减少镜像的体积。

Docker 的基本架构如下图所示:

具体来说:

  • Docker Client(客户端):Docker 客户端是用户与 Docker 交互的主要方式,通常指 docker 命令行工具(CLI)。用户通过 Docker CLI 发送命令(如 docker rundocker pull),然后这些命令会传递给 Docker 守护进程进行执行。

  • Docker Daemon(守护进程):Docker Daemon (dockerd) 是运行在 Docker Host 上的后台守护进程,负责管理容器的生命周期。它接收来自 Docker Client 的请求,处理容器的创建、运行、停止等操作,并与 Docker Registry 交互以下载或上传镜像。

  • Docker Host(Docker 主机、宿主机):Docker Host 是运行 Docker Daemon 的物理机或虚拟机,它提供了运行 Docker 容器的环境(容器实际运行在 Docker Host 中)。它通常就是本地计算机,但是也可以是云服务器,Docker 提供了远程管理的支持。

  • Docker Registry(注册服务器):Docker Registry 用于存储和分发 Docker 镜像。官方的 Docker Hub 是一个公共的 Docker Registry,用户可以从 Docker Hub 拉取镜像,也可以将自己的镜像上传到其中。一个 Docker Registry 通常包含大量仓库(Repository);每个仓库可以包含多个 标签(Tag);每个标签直接对应一个镜像。

一个仓库通常代表一个预设的系统或软件环境,可能会同时提供不同版本的镜像,使用标签进行区分, 可以通过 <仓库名>:<标签> 的格式来指定,如果不提供标签,将使用 latest 作为默认标签,表示最新的一个版本。

以 Ubuntu 镜像为例,ubuntu 是仓库的名字,仓库中包含有不同的版本标签,如,16.04, 18.04, 可以通过 ubuntu:18.04 来具体指定所需的镜像,缺省时,ubuntu 将视为 ubuntu:latest。 仓库名还可能以两段式路径形式出现,比如 jwilder/nginx-proxy,前者代表 Docker Registr 中的用户名,后者则是对应的软件名,不含用户名的仓库应该是注册服务器直接提供支持的。

虽然容器模拟了一个轻量级的Linux环境,在其中可以同时运行多个应用,但是通常并不推荐,推荐的做法是一个容器只运行一个进程,将不同的应用分散在不同容器中,例如一个 web 应用可能通过 web应用 + 数据库 + 缓存 这三个容器实现。

安装与配置

这里只考虑纯 Linux 环境,不考虑 Windows 的 WSL2,Linux 系统为 Ubuntu。

注意:Docker 的旧版本曾经被称为 docker.io,docker-engine 或 docker,这些名称很混乱,主要是早期有一个其它程序也叫docker,后来主动改名了。 如果已安装这些旧版本,需要使用下面的命令卸载它们

1
sudo apt-get remove docker docker-engine docker.io containerd runc

接下来需要安装的是 Docker Engine-Community 软件包 docker-ce,即社区版,与之对应的是 docker-ee,即商业版。

安装 Docker

记录一下在Ubuntu安装Docker的步骤:(参考Ubuntu 安装 Docker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 更新软件包索引
sudo apt-get update

# 安装 docker 的依赖包
sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release

# 添加 GPG 密钥
curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

# 向 APT 软件源列表中添加 Docker 源
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://mirrors.aliyun.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# 更新软件包索引
sudo apt-get update

# 安装 Docker
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin

Docker 服务

Docker 需要一个守护进程,安装之后似乎就是启动的,并且已经被设置为开机自启动,可以使用下面的命令查看或设置

1
2
3
4
5
6
7
8
9
10
11
# 查看 Docker 服务是否开机自动启动
sudo systemctl is-enabled docker

# 设置 Docker 服务开机自动启动
sudo systemctl enable docker

# 查看 Docker 服务状态
sudo systemctl status docker

# 启动 Docker 服务
sudo systemctl start docker

Docker 用户组

默认情况下,docker 命令会使用 Unix socket 与 Docker 引擎通讯,但是只有 root 用户和 docker 组的用户才可以访问 Docker 引擎的 Unix socket,这导致普通用户使用很多 docker 命令的权限不够。出于安全考虑,推荐的做法是将需要使用 docker 的用户加入 docker 用户组。

创建 docker 组以及添加用户的命令如下(需要sudo权限或者使用root)

1
2
3
4
5
6
7
8
# 建立 docker 组(有时安装docker时会自动创建(
sudo groupadd docker

# 将当前用户追加到 docker 组(用户通常自动属于与用户名同名的组)
sudo usermod -aG docker $USER

# 将alice追加到 docker 组
sudo usermod -aG docker alice

被修改的用户可能需要重新登录,以确保修改生效。

实践中可能需要下面这些命令进行辅助

1
2
3
4
5
# 测试 docker 组是否存在
getent group docker

# 检查当前用户属于哪些组
groups $USER

进行上述操作之后,一般的docker命令就不再需要使用sudo了,下文中的命令也不再使用sudo。

docker 镜像是共享的,docker 用户组的用户可以看到所有的镜像,并不局限于自己创建的镜像,但是容器并不是共享的,只有创建者的用户才能看到和操作自己创建的容器。

测试

Docker 提供了一个最简单的 hello-world 镜像用来测试 docker 是否安装成功。

1
docker run hello-world

此命令的含义为启动 hello-world 镜像,由于本地没有,会自动开始下载,并基于此镜像运行容器,然后打印 hello-world 并自动退出容器,顺利完成即表示 Docker 安装成功。

由于不可抗力的原因,在获取镜像时会遇到各种网络问题,上述测试没有成功,选择手动从指定镜像下载镜像,命令修改为

1
2
3
4
5
# 下载镜像
docker pull docker-0.unsee.tech/hello-world

# 运行容器
docker run docker-0.unsee.tech/hello-world

这里我们顺便下载一个基础的ubuntu镜像进行后续的操作

1
docker pull docker-0.unsee.tech/ubuntu

这个镜像里面几乎啥也没有,包括vimpingssh等基础工具都没有,在镜像任何实际操作之前需要apt-get update更新包索引,然后下载必要的工具。

考虑到网络原因,下文中的命令所使用的ubuntu都需要替换为docker-0.unsee.tech/ubuntu

关于网络问题的更多处理办法参考目前国内可用Docker镜像源汇总

镜像操作

查找和下载镜像

可以在注册服务器中搜索相关镜像

1
2
3
docker search mysql

docker search docker-0.unsee.tech/mysql

可以通过pull命令下载镜像

1
docker pull [IMAGE_NAME]:[TAG]

标签在缺省时视作latest

查看本地镜像

下面的命令可以查看当前本地的镜像列表

1
docker image list

返回的表格字段说明:

  • REPOSITORY: 镜像来自于哪个仓库;
  • TAG: 镜像的标签;
  • IMAGE ID: 镜像的唯一标识 ID, 如果看到两个 ID 完全相同,那么实际上它们指向的是同一个镜像,只是标签名称不同罢了;
  • CREATED: 镜像创建时间;
  • SIZE: 镜像的大小。

下面这些命令都是一样的,都是 Docker 提供的一组别名

1
2
3
docker image list
docker image ls
docker images

导入和导出镜像

可以将本地的镜像以压缩包形式导出,例如

1
docker save -o redis.tar redis:latest

与之配套的是从压缩包中导入镜像,例如

1
docker load -i redis.tar

删除镜像

下面的命令可以删除镜像

1
2
3
4
docker image rm [IMAGE_NAME]:[TAG]

# or
docker rmi [IMAGE_NAME]:[TAG]

如果本地存在一个镜像的多个版本(即多个标签),必须指定删除的镜像标签。

也可以直接使用 IMAGE_ID 来删除镜像,例如

1
docker rmi ee7cbd482336

在使用 Docker 一段时间后,系统一般都会残存一些临时的、没有被使用的镜像文件(可能是已删除的镜像所使用的中间层等),可以通过以下命令进行清理

1
docker image prune

容器操作

对容器的操作通常需要指定容器ID或者名称:

  • 容器 ID 是一个很长的随机字符串,在创建时自动生成,实际使用只需要提供足够唯一确定的前缀即可;
  • 容器名称可以在创建时通过--name指定,否则会提供一个随机名称,例如sweet_carson。(还可以指定--hostname,否则默认使用容器 ID)

启动容器

Docker 启动容器通常有两种情况:

  • 基于镜像新建一个容器并启动;
  • 将处于终止状态 (exited)的容器重新启动。

我们先关注第一种情况。

启动容器的命令形式如下

1
docker run [IMAGE]:[TAG] [OPTIONS]

通常需要加上不同的选项来实现不同的启动方式。

例如下面这个命令会启动一个 ubuntu 镜像,直接执行命令 /bin/echo "hello,docker"然后终止

1
docker run ubuntu:latest /bin/echo "hello,docker"

需要注意的是,一旦命令执行完毕,这个容器就会自动终止。

如果希望以交互的方式运行容器,可以参考如下命令:(默认以root用户进入,位于根目录/下)

1
docker run -it ubuntu:latest /bin/bash

其中使用的选项为:

  • -t: 让 Docker 分配一个伪终端(pseudo-tty)并绑定到容器的标准输入;
  • -i: 让容器的标准输入保持打开。

实践中我们更需要让容器在后台持续运行来提供服务,此时需要加入-d选项,例如

1
docker run -d ubuntu:latest /bin/sh -c "while true; do echo hello world; sleep 1; done"

这里的效果是让容器在后台保持运行,每隔一秒打印出hello world。 命令执行成功后会返回一个容器 ID。

可以使用下面的命令查看在后台运行的容器的日志

1
docker container logs [container ID or NAMES]

查看容器

下面的命令可以列出当前正在运行的容器

1
docker container list

加上-a选项可以列出所有容器,包括当前正在运行的和已经停止的容器

1
docker container list -a

可以筛选最新创建的几个容器,例如

1
docker container list -a -n=2

下面这些命令都是一样的,都是 Docker 提供的一组别名

1
2
3
4
docker container list
docker container ls
docker container ps
docker ps

停止容器

对于交互式运行的容器,直接输入exit或者Ctrl+D都可以退出容器。(ctrl+p,ctrl+q 则可以退出处于交互式的容器,但不会关闭容器)

对于在后台运行的容器,可以使用下面的命令停止

1
2
3
docker container stop [container ID or NAMES]
# 可省略关键字 container
docker stop [container ID or NAMES]

除了stop,还可以使用kill强制关闭容器

1
2
3
docker container kill [container ID or NAMES]
# 可省略关键字 container
docker kill [container ID or NAMES]

重启容器

下面的命令可以重启终止状态的容器

1
2
3
docker container restart [container ID or NAMES]
# 可省略关键字 container
docker restart [container ID or NAMES]

进入容器

使用 docker exec 命令可以进入一个正在后台运行的容器,例如

1
docker exec -it [container ID or NAMES] /bin/bash

这里必须提供两个位置参数,通常是容器ID和一个shell。

启动的终端可以通过 exitCtrl+D 退出,这并不会导致容器终止。

除此之外,还有一个过时的 docker attach 命令也可以达到类似效果,但是不再推荐,因为它的退出会导致容器终止。

导入和导出容器

使用 docker export 命令可以导出容器为压缩包,其中包含了容器中文件系统的完整拷贝,例如

1
docker export 9e8d11aeef0c > redis.tar

使用 docker import 命令可以将快照导入为镜像,例如

1
cat redis.tar | docker import - test/redis:v1.0

除此之外还有docker commit命令,也可以基于容器当前状态创建镜像,但是与这里的做法不同,它可以保存原始镜像的元数据。

删除容器

使用下面的命令可以删除指定的已经停止的容器

1
2
3
docker container rm [container ID or NAMES]
# 可省略关键字 container
docker rm [container ID or NAMES]

添加-f选项还可以强制删除一个正在运行的容器。

使用下面的命令可以一次性删除所有已经停止的容器

1
docker container prune

数据管理与数据卷

Docker 镜像由多个文件系统(只读层)叠加而成,在容器启动时,Docker 会加载只读镜像层,在镜像栈顶部添加一个读写层。 如果在运行的容器中修改了某个已存在的文件,那么该文件将会从下面的只读层复制到上面的读写层,同时,该文件在只读层中仍然存在,并且保持未修改的原始状态。 当我们删除 Docker 容器,并通过镜像重新启动容器时,之前的更改的文件将会丢失,也就是说在容器中的数据修改默认是会丢失的。

如果我们希望将数据持久化,可以使用数据卷:数据卷是一个可供一个或多个容器使用的特殊目录,用于持久化数据以及共享容器间的数据,它以正常的文件或目录的形式存在于宿主机上。数据卷的生命周期独立于容器的生命周期,删除容器时,它使用的数据卷并不会被同时删除。

使用下面的命令可以查看当前的所有数据卷

1
docker volume ls

创建数据卷的命令如下

1
docker volume create test-vol

以这种方式创建的数据卷默认位于宿主文件系统的 /var/lib/docker/volumes 目录下。

与Docker相关的本地资源都存放在 /var/lib/docker/ 目录下,例如其中的子目录 containers存放容器信息。

使用下面的命令可以查看指定数据卷的详细信息

1
docker volume inspect test-vol

下面提供一个挂载数据卷的示例,运行一个 Nginx 容器,并通过--mount选项挂载该数据卷

1
2
3
4
5
docker run -d -it \
--name=test-nginx \
-p 8011:80 \
--mount source=test-vol,target=/usr/share/nginx/html \
nginx:1.13.12

参数说明:

  • -d -it: 后台运行容器,开启一个终端;
  • --name=test-nginx: 指定容器名为 test-nginx
  • -p 8011:80: 将容器的 80 端口挂载到宿主机的 8011 端口;(还有一个类似的-P选项,代表随机端口)
  • --mount
    • source=test-vol:指定数据卷名称为 test-vol
    • target=/usr/share/nginx/html: 值得挂载到容器中的 /usr/share/nginx/html 目录。

挂载成功后,不论是修改 /var/lib/docker/volumes 下的数据,还是进入到容器中修改 /usr/share/nginx/html 下的数据,都会自动同步修改,类似前端开发中双向绑定的作用。 挂载的目录对于容器来说默认是允许读写的,但是也可以加入选项 readonly 使其对于容器内的文件系统变为只读状态。

除了专门的数据卷,还可以直接指定挂载一个本地主机的目录到容器中去

1
2
3
4
5
docker run -d -it \
--name=test-nginx \
-p 8011:80 \
--mount type=bind,source=/src/webapp,target=/usr/share/nginx/html \
nginx:1.13.12

这里需要指定type=bind,前文中数据卷的做法相当于type=volume,只是被默认省略了。

除了--mount选项,还有一个更早提供的-v选项,示例如下

1
2
3
4
5
docker run -d -it \
--name=test-nginx \
-p 8011:80 \
-v test-vol:/usr/share/nginx/html \
nginx:1.13.12

使用-v--mount存在一点区别:如果宿主机中没有指定文件,-v会自动创建指定文件,--mount则会报错找不到指定文件。因此从防止误操作的角度,更建议使用--mount

使用下面的命令可以删除数据卷

1
docker volume rm test-vol

如果需要在删除容器的同时移除数据卷,可以在删除时加上-v选项

1
docker rm -v ...

注意:docker 本身并不支持在容器运行状态下的数据卷热插拔。

Dockerfile

Dockerfile 是一个特殊的文本文件(文件名即Dockerfile),里面写着构建 Docker 镜像的指令。 它通过描述式的语句告诉 Docker 如何一步步打包应用程序和运行环境。 不讨论详细的语法细节,这里只提供一个使用 Docker 打包 Nginx 服务器的例子。

1
2
3
4
5
6
7
8
# 指定基础镜像
FROM nginx:latest

# 复制本地文件到容器
COPY ./index.html /usr/share/nginx/html/

# 指定容器要暴露的端口
EXPOSE 80

注:

  • 由于docker镜像是分层构建的,这里的每一个实际操作都会产生一个新的层,将主要的步骤分层可以在构建相似镜像时充分利用这里的中间层,从而提高构建速度;
  • 这里的EXPOSE只是提示性质的,并不代表容器实际暴露的端口;
  • 这里还可以指定CMD字段,它表示容器启动时的默认命令,没有时需要在启动命令中指定,否则会启动一个默认的shell(例如/bin/bash)。

使用下面的命令构建镜像

1
docker build -t my-nginx:1.0 .

在构建时会自动使用当前目录下的 Dockerfile 文件,也可以通过 -f 参数指定 Dockerfile 文件的路径。

运行容器

1
docker run -d -p 8080:80 my-nginx:1.0

访问 http://localhost:8080 即可看到 Nginx 提供的页面。

补充

联网问题

从容器内部访问外部网络默认应该是允许的,如果遇到容器内部联网问题,比如apt update失败,可以尝试在启动时加上--net=host选项,此时容器会直接使用宿主机的网络,不需要端口映射。

必须要说明的是,ufw对docker转发端口不起作用,因为 Docker 为了支持容器的网络配置,绕过了 UFW 直接修改了 iptables,所以一个容器可以绑定一个端口。 这就意味着,所有设置的 UFW 规则都将在 Docker 容器中失效。

可以使用下面的做法禁止 docker 自动修改 iptables,参考 Docker UFW 失效

1
2
sudo vim /etc/docker/daemon.json
# 如果文件不存在,直接新建

添加如下配置

1
2
3
{
"iptables": false
}

然后重启docker服务

1
sudo systemctl restart docker

但是这带来的后果是在docker容器中的联网可能存在问题,使用--nat=host直接使用宿主机网络是最简单的做法。

其它更复杂的网络配置这里不做讨论。

时间问题

在容器中默认可能是UTC标准时间,可以使用下面的命令检查时区

1
date +"%Z %z"

可以用下面的命令修改为北京时间

1
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

ssh登陆容器

下面记录一下如何让一个简单的 ubuntu 容器支持从外部ssh登陆。

首先准备好端口映射,例如在启动将22号端口映射到宿主机的2222号端口。

1
docker run -dit -p 2222:22 ubuntu:latest

这里端口映射对于bridge网络模式有效,对于host网络模式没有意义。在host网络模式下,还需要在容器内部修改sshd监听端口。

然后重新进入docker容器进行后续操作。

1
docker exec -it ubuntu:latest /bin/bash

我们需要安装一些必要的基础软件

1
2
3
4
5
apt-get update

apt-get install vim

apt-get install openssh-server openssh-client

启动sshd守护进程

1
/etc/init.d/ssh start

查看进程是否启动

1
ps -e | grep sshd

如果使用--net=host,这里很可能启动失败,因为22号端口已经被宿主机的sshd服务占用了,需要修改容器中sshd服务监听的端口。(ssh远程登陆时,通过-p指定端口)

编辑sshd的配置文件

1
vim /etc/ssh/sshd_config

PermitRootLogin prohibit-password修改为PermitRootLogin yes 允许root用户远程登陆。

重启 ssh 服务使修改生效

1
service ssh restart

在本地直接进入docker容器默认就是环境中的root用户,不需要密码也没有密码。为了允许从外部ssh登陆,必须给root用户设置密码

1
passwd root

实际上,即使这里设置了root密码,通过docker exec重新进入容器仍然是不需要密码的。

配置完成之后,通过ssh即可实现远程访问,并且这不是登陆宿主机再进入容器,而是直接登陆docker容器。

在容器中使用 GPU

在容器中通常无法直接使用宿主机的GPU,需要进行一些额外配置,这部分操作主要参考(解决方案)docker could not select device driver |Docker 无法成功分配或访问GPU资源

首先检查本地的Nvidia驱动,CUDA版本等,选择拉取NVIDIA提供的对应版本的基础镜像

1
docker pull nvcr.io/nvidia/cuda:12.4.0-runtime-ubuntu20.04

然后安装nvidia-docker2,这里需要先添加仓库,更新软件源索引

1
2
3
4
5
6
7
8
9
10
# 设置仓库和GPG密钥
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list

# 更新软件源索引
sudo apt-get update

# 安装 nvidia-docker2
sudo apt-get install -y nvidia-docker2

为了使修改生效,需要重启docker服务

1
sudo systemctl restart docker

可以启动这个基础镜像,启动时加上--gpus all选项

1
docker run --gpus all -it nvcr.io/nvidia/cuda:12.4.0-runtime-ubuntu20.04

可以在容器中使用nvidia-smi进行测试,输出正常即代表容器可以使用宿主机的GPU资源。