坑边闲话:容器技术作为微服务的底层,现已融入到开发、部署里。在许多教学场景下,教师为了方便学生集中注意力于课程实质,一般会提供配置好的开发环境并打包为容器镜像,然后进行统一发布。容器的底层细节非常复杂,而且只有在 Linux 内核下才有较为良好的实现,macOS 等 UNIX 和 Windows NT 目前只能靠底层安装一个 Linux 虚拟机的形式提供容器服务。Linux 容器技术缩写为 LXC. 然而本文不尝试从容器技术的前世今生开始逐步探讨,而是试图提供一个快速上手的引导。

1. 安装容器系统组件·

Container 技术发展了这么多年,已经较为成熟。最初的容器技术是由 docker.io 这家公司搞出来的,后来诸多行业大佬发现这块蛋糕无比巨大,十分有必要把它从 Docker 公司手里抢过来。于是 EMC、Redhat 等大牌公司联合起来,搞了个开放容器组织 OCI,试图架空 docker 公司在容器技术上的话事人角色。另一方面,Docker 公司的 Docker 产品确实存在一些问题,比如需要一个 docker-daemon 守护进程,而且 docker 命令要以 root 权限运行。这些都带来了一些安全隐患!

小贴士

其实从本质上讲,没有 Docker 公司也会出现容器技术,毕竟在这么多年的运维呼吁下,标准化的服务部署已经呼之欲出。然而,Docker 公司在容器的发展史上有不可磨灭的贡献,其中之一就是发明了容器镜像的标准化技术。至于贡献了 containerd 等,还无法与之相提并论。

或许没有爱因斯坦,同时期也会有别人发明狭义相对论;但是如果没有爱因斯坦,人类到现在可能都无法认识到广义相对论思想。

碎碎地说了上面这么多,笔者是想引出今天的安装教程。尽管容器已经很火,而且 DockerHub 用的人也特别多,但是我们今天想通过 podman 来进行讲解。前面提到诸多行业大佬想瓜分容器技术,不让 Docker 公司主导技术走向,于是大佬们就联合起来搞了 OCI 标准。该标准定义了容器的进程隔离细节、镜像的文件系统细节等。以前这种全行业商讨标准的事情发生过无数次了,比如全行业一起制定 USB 技术标准、Linux KVM 技术标准等。

以前在命令行里调用 docker 命令,现在就可以原模原样地调用 podman 命令。docker 程序是 docker 公司提供的,现在基本不用了。podman 是新时代的命令!当然,如果你不喜欢 podman,使用传统 docker 命令也是完全适用本文的

其实不管用 podman 还是 docker,本质上都是类似的。现在 Kubernetes 不要求特定的容器运行时环境,只要能符合容器标准,均可融入到 Kubernetes 的生态中。因此,读者亦可基于传统 docker 进行学习。

那么问题来了,如何安装 podman 呢?在这里我建议新手直接安装 RedHat 8.4 版本的完整版系统,该系统自带最为完整的 podman 环境,而且红帽的企业级系统极为稳定,对 NVIDIA GPU、深度学习框架等软硬件环境的支持也非常完善。

2. 创建镜像·

新手入门 Docker 技术,不建议从容器规范开始,毕竟自主构建容器已经是较为高级的操作。建议新手跳过本节,直接学习 docker 的命令。

Docker 镜像是一种分层结构。不同的 layer 堆叠起来,构成了最终的镜像。Dockerfile 采用一种简单的类似 shell 脚本的语言,将如何构建一个镜像的过程描述了出来。用户按照 Dockerfile 的指示,即可自动构建出最终的镜像。接下来笔者将要介绍常见的 Dockerfile 语句。

Dockerfile 的不同命令会产生不同的 layer,而有些命令不会产生 layer.

2.1 会创建新层的 Dockerfile 指令·

以下指令在执行时会创建一个新的层:

  • RUN:执行命令并创建新层。每个 RUN 指令都会在镜像上添加一个新层。
  • COPY:将文件或目录从构建上下文复制到镜像中,并创建一个新层。
  • ADD:类似于 COPY,将文件或目录以及远程 URL 的内容复制到镜像中,也会创建新层。
  • CMD:虽然通常只影响在容器启动时执行的命令,并不会增加镜像大小,但它在技术上定义了镜像的一层(这层是元数据层,大小几乎为零)。
  • ENTRYPOINT:用于配置容器启动时执行的命令,与 CMD 类似,通常只是设置元数据。
  • EXPOSE:声明端口,虽然不会增加存储层,但会作为元数据层存在。
  • ENV:设置环境变量,每个 ENV 指令添加一层,因为它修改了镜像的环境设置。
  • LABEL:添加元数据到镜像,每个 LABEL 指令也是一层。

在实际应用中,为了减少镜像的层的数量和大小,通常建议合并多个 RUN 指令,例如使用 shell 命令的逻辑链接(如 &&)来执行多个命令。此外,合理使用 .dockerignore 文件来排除不需要的文件和目录也是优化构建过程的一部分。理解这些指令如何影响 Docker 镜像的层结构对于构建高效、易于维护的容器化应用至关重要。

2.2 不会创建新层的 Dockerfile 指令·

这些指令不会增加新的层,因为它们不修改镜像的文件系统:

  • ARG:定义传递给构建运行时的变量。
  • FROM:设置基础镜像,尽管它本身是每个阶段的起点,但它不会“添加”新层,而是初始化构建阶段。
  • MAINTAINER(已废弃,现在使用 LABEL 代替):作者标签,不添加新层。
  • ONBUILD:为将来的构建触发指令,不立即添加新层。
  • STOPSIGNAL:设置系统调用信号,不添加新层。
  • USER:设置运行容器时的 UID/GID,不增加新层。
  • WORKDIR:设置工作目录,这虽然会影响后续指令(如 RUNCMD),但本身并不增加新层。
  • HEALTHCHECK:设置容器的健康检查命令,本身不增加新层。

2.3 Dockerfile 范例·

下面是创建一个 flask 服务容器的 Dockerfile,读者可简单阅读,结合注释理解作者的意图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Use the latest Python image as the base image
FROM python:latest

# Set the working directory in the container
WORKDIR /work

# Install Supervisor
RUN apt-get update && apt-get install -y supervisor

# Install the `requests` package using pip
RUN pip install requests Flask

# Copy the files from the local directory to the /work directory in the container
COPY . /work

# Supervisor configuration
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf

# Expose port 80
EXPOSE 80

# Run Supervisor when the container starts
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

3. 常用命令·

如果你喜欢 docker 这个命令而且使用 zsh,那么可以在你的 .zshrc 文件里添加一句:

1
alias docker=podman

然后就可以愉快地以 docker 之名玩转 podman 了。就我本人而言,我更喜欢 docker,因为这个命令输入的时候不需要动小拇指,输入的键盘效率要高一些。输入 docker 只需要动左手的食指、中指以及右手的中指和无名指,而输入 podman 就需要动左手和右手的小拇指,这简直太难受了。动一只手的小拇指还好,两只手都要动,简直无法想像 OCI 的人是如何想到这个命令的

图 1. Docker 是 Podman 的别名

3.1 运行镜像:简单方法·

1
docker run <某镜像名>

以上就是最简单的 docker 执行命令。通过 docker run 一个镜像名,可以迅速以该镜像为基础在本机上跑起一个容器。在这里我们要明白一点,镜像是一个可以磁盘存储的文件。而容器是一个运行时概念,它指的是在 run 的一个东西,当容器 stop 之后它也就不存在了。 这有点类似 Class 与 Instance 的关系。

作为一个在运行的系统,容器肯定可以接受一些命令。这有点类似于我们执行一个虚拟机,然后在虚拟机里执行命令。具体的做法是:

1
docker run <某镜像名> '某命令 A' 'A 命令的参数'

1
docker run ubuntu18 '/bin/echo' 'hello'

注意,你可能会问,直接运行 docker run ubuntu18 'echo' 'hello' 不行吗?毕竟在 Ubuntu 18.04 里该命令也能很好地执行啊?答案是不行。原因在于官方的 Ubuntu 18.04 docker 镜像非常精简,环境变量都是空白,所以必须把 echo 命令的绝对路径输进去才能识别!

3.2 运行镜像:加点参数·

docker 虽然没有 hypervisor,但是从使用角度看确实有点像虚拟机。一般我们跑起一个 Linux 虚拟机之后都可以在其中跑点 termianl 之类的东西。docker 自然也不例外。

1
2
3
4
5
6
# 给容器分配一个 terminal 并将此 terminal 绑定到该容器的标准输入上
docker run -t=true <某镜像名>

# 默认 -t=false
# -t 是 --tty 的缩写
# 一般在交互式地使用 docker run 命令时才用这个 --tty / -t 参数

所以为了交互式地使用容器,一般会使用下面这个命令组合:

1
2
3
4
5
docker run -it <某镜像名>

# -i 是 --interactive 的缩写,即交互式地
# -i 会打开容器的标准输入 stdin
# -it 就是打开容器的标准输入、标准输出、标准错误输出

到了这儿我们可以执行一个最基本的交互式容器了:

1
docker run -it localhost/ubuntu /bin/bash

运行上面这个命令,就可以在当前命令行里打开容器的交互式终端。

图 2. -ti 参数

3.3 停止容器·

1
2
3
4
5
# 停止一个运行中的容器
docker stop <某镜像名>

# 杀掉一个运行中的容器
docker kill <某镜像名>

两个命令都是停止容器,不同之处在于 docker stop 先发 SIGTERM (即 signal of terminate) 信号给容器,允许其在一定时间(默认 10s)内进行一些操作(例如资源回收),若这段时间内容器未停止,则发送 SIGKILL (signal of kill) 信号强行杀死容器;而docker kill 直接发送 SIGKILL 信号杀死容器。

如果你的容器打开了一些文件,建议还是使用 docker stop 然后等十秒钟为妙,直接 docker kill 有一定危险性!

3.4 删除容器·

1
2
3
4
5
# 删除一个已经停止运行的容器
docker rm <某镜像名>

# 强制删除正在运行的容器
docker rm -f <某镜像名>

容器既然是一个运行时概念,那么是不是 stop 或者 kill 之后就没了呢?其实还有有的。这个地方很不好理解,需要彻底理解为什么 docker stop 某容器之后它依旧还存在,需要从底层了解一些 docker 的架构和实现原理,这对于小白朋友不太友好。不过我还是得讲讲。作为容器技术而言,镜像是非常庞大的,比如某些大型镜像轻则几百兆字节,多则几个 G 字节。那么我们仅仅相对某个现有镜像做点修改,然后打包成一个新的镜像可以吗?当然可以。但是这么做会不会把原来的文件复制一份呢?不会的。docker commit 提交更改过的镜像时用了增量存储技术,也就是你改多少它提交多少。这是不是很聪明呢?

话说回来,当你运行了某些容器时,就必然对这个容器所对应的镜像里的文件做了一点修改,这时候这些修改并不完全是在内存里、伴随着容器的 stop 而消失。其实这些修改会被缓存到磁盘上。当你不小心关掉了容器,那么系统并不会立即删除先前容器活着的时候的缓存。所以到了这里你就明白 docker rm 的意义了,那就是删除容器运行过的痕迹!

3.5 复活容器·

1
2
3
4
docker start <某容器 ID>    # ID 是一串哈希码
docker attach <容器 ID>

# 上述两个命令也可以合并使用:docker start <Container_ID> --attach

在删除容器里我们讲到,容器哪怕被 stop 了也会存在一些缓存,如果你想删掉这些缓存就可以用 docker rm 清理掉。如果你想让容器活过来,就可以用 docker start 命令。让它活过来以后,如果这个容器还有一个 termianl 在后台执行,那么可以使用 docker attach 再附着到原来的 terminal 上去。这简直是手残党的利器!

图 3. docker start

图 4. docker ps

通过 start 启动的容器会保留上一次结束前做的变动,不会像 run 一样执行的是全新的容器。

3.6 导入导出容器·

1
2
3
4
5
6
7
# 将容器导出为镜像
docker export <Container_ID> -o demo1.tar
# 或者使用输出重定向符号 >
docker export <Container_ID> > demo1.tar

# 导入镜像
docker import demo1.tar xxx/<Image_Name>

docker export 指的是持久化容器,类似给一个虚拟机打快照,会保留容器启动后的一系列修改;docker save 持久化一个镜像,只是将镜像导出,如果通过镜像启动容器,那么容器中的修改并不会写入镜像中,也因此不会被 save. 要注意,export 导出后的镜像会丢失历史,等于没法回滚到以前的某个状态,而 save 则保留全部的历史;也因此 export 导出后的镜像更小。

另外,export 一次只能导出一个容器,而 save 可以一次性导出多个镜像。

实际使用中,仅仅导出镜像使用 save,而如果对容器做了修改后需要保存,可以 export,也可以先 commitsave.

3.7 后台运行某容器·

1
docker run -d <Image_Name>

-d 指的是 detach 模式运行。

这里我们要澄清一个重要的事情,那就是指定 -d 命令并不会让容器在后台一直跑,容器能否在后台长久运行,和 -d 以后赋予的命令有关。如果仅仅指定 -d 参数,那么容器也会立即停止并进入 exited 状态。(可通过 docker ps -a 命令查看)

那么如何让容器一直活着但是不随着我的命令行关闭而关闭呢?这个问题很重要,特别是当我们的命令行是远程连接的时候更要命。因为我们知道在 terminal 里打开的所有进程都是 shell 程序的子进程,shell 死后其子进程也都关闭。通常我的做法是在 tmux 里执行一个容器的 bash 交互窗口,然后就可以 detach 这个 tmux session,容器依旧跑在 tmux 那边。当然,你执行一个死循环也可以,我只是觉得那样不够优雅。

容器本身是一个运行时概念,可以将其简单理解为一个操作系统进程

传统的进程需要在被装入内存之后保持执行状态,然后在系统调度器的安排下在 pending、ready、executing 等状态之间来回切换。当程序执行结束,比如到了退出指令,程序就自动返回。所以原则上容器也应该保持这样的行为:

  • 容器在启动之后,开始按照 CMD 等要求,启动存储在镜像中的程序,或以 daemon off 的前台模式方式长期提供服务,或执行一段计算指令之后退出;
  • 对于服务型容器,比如 CMD 要求启动一个 nginx 在后台跑,除非我们手动关停,否则就一直运行;
  • 对于执行一段计算指令的容器,执行完就 exit,无法持久化。

容器的生命周期与其主进程(即由 CMDENTRYPOINT 指令指定的进程)绑定。对于许多基础操作系统镜像,例如 debian:latest,默认的 CMD 可能是一个基本的 shell,如果没有交互式命令来维持它的运行,它会立即退出。当该进程退出时,容器也就结束了。

图 5. 使用 -d 参数,可以让容器以 detached 模式运行。

总结·

本文详细介绍了 Docker 容器技术的前世今生以及入门概念,读者学习完应该对如何使用容器有了初步的概念。

接下来我们将循序渐进,讲解 docker compose 和 Kubernetes 等相关概念。