Docker训练营(一)-Docker基础
Docker前置核心概念
【核心概念解析】
- 镜像(Image):
- 比喻:你可以把镜像想象成一个制作蛋糕的“模具”,或者一个电脑的“操作系统安装包”。它是一个只读的模板,里面包含了运行一个应用程序所需的所有东西:代码、运行时环境(比如 Python、Node.js)、库、环境变量和配置文件。
- 作用:镜像用于创建容器。你不会直接运行一个镜像,而是基于它“制作”出一个个运行实例。
- 容器(Container):
- 比喻:如果说镜像是一个蛋糕模具,那么容器就是用这个模具做出来的“蛋糕”本身。它是一个独立、可运行的软件包。或者说,它是你从操作系统安装包(镜像)安装出来的一个个独立的“虚拟机实例”,但比虚拟机轻量得多。
- 作用:容器是应用程序实际运行的地方。每个容器都是相互隔离的,拥有自己的文件系统、进程空间、网络接口等。
- Docker 守护进程(Docker Daemon / Server):
- 比喻:它就像一个“管家”或者“总指挥”。
- 作用:Docker 守护进程是 Docker 平台的核心组件,它在后台运行,负责构建、运行、分发你的 Docker 镜像和容器。你通过
docker
命令行工具发送的命令,都是先发送给这个守护进程,然后由它来执行。
- Docker 客户端(Docker Client):
- 比喻:它就像你手里的“遥控器”。
- 作用:你平时在终端输入的
docker
命令就是 Docker 客户端。它负责接收你的命令,并将这些命令发送给 Docker 守护进程去处理。
Docker 基础
cnb 云原生开发环境中已经预装了 docker,无需学员手动安装,直接体验即可,可使用如下命令来查看 docker 信息
1 | docker version #查看版本信息 |
- version
这条命令的作用是显示你的 Docker 客户端和 Docker 守护进程(即 Server)的详细版本信息。
- Client:
- Version: 26.1.3:你使用的 Docker 客户端工具的版本。
- API version: 1.45:客户端与 Docker 守护进程通信的 API 版本。
- Go version: go1.21.10:构建这个客户端所使用的 Go 语言版本。
- OS/Arch: linux/amd64:客户端运行的操作系统和架构(这里是 Linux 系统的 AMD64 架构)。
- Context: default:当前 Docker 客户端连接的上下文。在高级用法中,你可以连接到远程 Docker 主机,此时会有不同的上下文。
- Server: Docker Engine - Community这部分是关于 Docker 守护进程(服务器)的信息。
- Engine:
- Version: 26.1.3:Docker 引擎(守护进程)的版本。通常客户端和服务器版本会保持一致或接近。
- API version: 1.45:守护进程支持的 API 版本。
- Go version: go1.21.10:构建 Docker 引擎所使用的 Go 语言版本。
- OS/Arch: linux/amd64:Docker 引擎运行的操作系统和架构。
- containerd:这是一个守护进程,它负责容器的生命周期管理,如创建、启动、停止容器。Docker 引擎底层使用了 containerd。
- runc:这是一个轻量级的通用容器运行时,它实现了 OCI(Open Container Initiative)规范,负责实际运行容器内的进程。
- info
这条命令会显示关于 Docker 守护进程的详细系统信息,比如有多少个容器在运行、有多少个镜像、存储驱动是哪种、网络插件等等。它能让你对 Docker 环境有一个全面的了解。
- Client:这部分和
docker version
中的客户端信息类似,重复显示。- Server:
- **Containers: 0 (Running: 0, Paused: 0, Stopped: 0)**:显示当前系统上容器的总数以及它们的不同状态(运行中、暂停、已停止)。你的输出显示当前没有运行任何容器。
- Images: 0:显示本地存储的镜像数量。在你执行
docker pull
之前,这里是 0。- Storage Driver: overlay2:这是 Docker 默认使用的存储驱动之一。它是一种联合文件系统,允许多个文件系统层叠在一起,用于高效地存储镜像和容器数据。
- Logging Driver: json-file:Docker 容器默认的日志记录方式。
- Swarm: inactive:Docker Swarm 是 Docker 的原生集群管理工具,这里显示它没有启用。
- Runtimes: io.containerd.runc.v2 runc:显示 Docker 支持的容器运行时。
- Kernel Version: 5.4.241-1-tlinux4-0017.16:你的宿主机的 Linux 内核版本。Docker 很大程度上依赖 Linux 内核的功能。
- Total Memory: 743GiB:宿主机的总内存大小。
- Docker Root Dir: /var/lib/docker:这是 Docker 存储所有镜像、容器、卷等数据的地方。
运行你的第一个容器
1 | docker run hello-world |
跟所有的技术学习起点一样,上述命令使用 docker 运行一个最简单的 hello-world 容器,它的功能仅仅是在屏幕上打印一些文字。
docker run
首先会去本地寻找 hello-world 镜像,如果本地没有,则会从默认的 Docker 镜像仓库中拉去,也就是 Docker Hub。
镜像的格式一般为
1 | <repository>/<image>:<tag> |
如果 repository 为空,默认为 Docker Hub, tag 为空,则默认为 latest,
如下是一个从 cnb 制品库中的镜像示例, 其中 repository
为 docker.cnb.cool,image
为 looc/git-cnb,tag
为 latest。
1 | docker.cnb.cool/looc/git-cnb:latest |
镜像分为 public 和 private 两种,对于 public 的镜像无需登录即可拉取,对于 private 的镜像则需要登录后才能拉取,登录命令如下
1 | docker login <repository> |
这是第一次真正“运行”一个容器。
- **
docker run
**:这是一个核心命令,用于 创建并启动 一个新的容器。 hello-world
:这是你要运行的镜像名称。
当你运行这条命令时,Docker 做了这些事情(你的输出中也清晰地说明了):
Unable to find image 'hello-world:latest' locally
:- 解释:Docker 客户端告诉 Docker 守护进程,我想运行
hello-world
镜像。守护进程首先检查本地是否已经有这个镜像。发现本地没有。 latest
是什么?:latest
是镜像的标签(Tag)。一个镜像可以有多个标签,latest
通常指向最新版本。如果你不指定标签,Docker 默认会使用latest
。
- 解释:Docker 客户端告诉 Docker 守护进程,我想运行
latest: Pulling from library/hello-world
:- 解释:由于本地没有,Docker 守护进程会去 Docker Hub(这是一个官方的、公共的 Docker 镜像仓库,你可以理解为镜像的“应用商店”)拉取
hello-world
镜像。 - **
Pulling
**:这个动作就是“拉取”,从远程仓库下载镜像到本地。
- 解释:由于本地没有,Docker 守护进程会去 Docker Hub(这是一个官方的、公共的 Docker 镜像仓库,你可以理解为镜像的“应用商店”)拉取
e6590344b1a5: Pull complete
:- 解释:镜像是由多层(layer)组成的,每一层都有一个唯一的 ID。这里显示其中一层已经下载完成。这种分层机制使得镜像共享和更新非常高效。
Status: Downloaded newer image for hello-world:latest
:- 解释:镜像已经成功下载到你的本地了。
Hello from Docker! ... This message shows that your installation appears to be working correctly.
:解释:这是从
1
hello-world
容器内部运行的程序输出的信息。它告诉你 Docker 已经成功地:
- 联系了 Docker 守护进程。
- 从 Docker Hub 拉取了
hello-world
镜像。 - 从这个镜像创建并运行了一个新的容器。
- 将容器内部程序的输出(
Hello from Docker!
)流式传输到你的终端。
实践案例: 运行 Alpine Linux 容器
Alpine 镜像在企业生产环境中被广泛应用,它是一个极简的 Linux 发行版,
只包含最基本的命令和工具,因此镜像非常小,只有 5MB 左右,并且内置包管理系统 apk
, 使其成为许多其他镜像的常用起点。
拉取镜像
1 | docker pull alpine #拉取镜像 |
docker pull
:这个命令专门用来从远程仓库(默认是 Docker Hub)拉取(下载)镜像到你的本地。**
alpine
**:要拉取的镜像名称。这与
docker run hello-world
中的下载步骤类似,但docker pull
只负责下载镜像,不立即运行容器。当你只是想预先下载好某个镜像,以便后续使用时,会用到这个命令。
- **
docker image ls
**:这个命令用来查看你本地已经下载的所有 Docker 镜像。输出的每一列的含义:
- REPOSITORY:镜像的仓库名称,比如
alpine
、hello-world
。- TAG:镜像的标签,通常表示版本号,比如
latest
。- IMAGE ID:镜像的唯一 ID。它是镜像内容的哈希值,每个镜像 ID 都是独一无二的。
- CREATED:镜像创建的时间。
- SIZE:镜像的大小。你会发现
alpine
镜像非常小(8.3MB),这是一个非常轻量级的 Linux 发行版,非常适合 Docker。hello-world
只有 10.1KB,因为它只包含一个简单的执行文件。
运行容器
1 | docker run alpine ls -a #运行容器 |
docker run alpine ls -a
:
基于
alpine
镜像运行一个容器,并在容器内执行ls -a
命令。这条命令结合了
docker run
和一个具体的命令ls -a
。
- **
docker run alpine
**:同样是创建并启动一个基于alpine
镜像的容器。- **
ls -a
**:这是你希望在这个新容器中执行的命令。ls -a
是 Linux 命令,用于列出当前目录下的所有文件和文件夹(包括隐藏文件)。这个命令的背后逻辑:
- Docker 根据
alpine
镜像创建并启动了一个全新的容器。- 在这个容器内部,它执行了你指定的
ls -a
命令。ls -a
命令的输出(也就是那些.
,..
,bin
,dev
等目录)被返回并显示在你的终端上。- 因为
ls -a
命令执行完成后,容器内的程序就结束了,所以这个容器会立即停止并退出。
docker ps -a
:用于列出当前正在运行的容器。
-a
或--all
:这个选项非常重要,它表示“显示所有容器”,包括那些已经停止(Exited)的容器。如果没有-a
,你将看不到已经停止的容器。- 你在这里看到了
Exited (0)
,这说明hello-world
和你刚刚运行的alpine ls -a
这两个容器都执行完任务后就退出了。这是正常的行为,因为它们的任务就是执行一个简单的命令然后退出。
交互式运行容器
docker run 命令默认使用镜像中的 Cmd 作为容器的启动命令,Cmd 可通用如下命令来查看。
1 | docker inspect alpine --format='{{.Config.Cmd}}' |
可以看到默认的 Cmd 为 ["/bin/sh"]
,因此直接使用 docker run alpine
会启动 /bin/sh 这个 shell,
我们 期望可以在这个 shell 中执行一些命令,但实际上它只是启动了 shell,退出了 shell,然后就停止了容器。
- **
docker inspect
**:这是一个非常强大的命令,用于获取 Docker 镜像、容器、卷、网络等任何 Docker 对象的详细底层信息(JSON 格式)。- **
alpine
**:你要检查的对象(这里是alpine
镜像)。--format='{{.Config.Cmd}}'
:这个选项用于指定输出的格式。它使用 Go 模板语法,让你只提取你关心的特定字段。
- **
.Config
**:代表镜像的配置信息。- **
.Cmd
**:代表容器启动时默认执行的命令。输出
[/bin/sh]
解释: 这意味着alpine
镜像在没有明确指定其他命令时,默认会启动/bin/sh
这个 shell 程序。sh
是一个 Unix shell,允许你在命令行中与系统交互。
1 | docker run -it alpine #等效于 docker run -it alpine /bin/sh, 使用 -it 参数启动容器进入交互式终端 |
- **
docker run alpine
**:仍然是创建并启动一个基于alpine
镜像的容器。-i
或--interactive
:这个选项表示交互模式。它会保持标准输入 (STDIN) 打开,即使没有附加到容器。这意味着你可以向容器输入命令。-t
或--tty
:这个选项会为容器分配一个伪终端(pseudo-TTY)。这使得容器的输出可以像在普通终端上一样显示,对于交互式会话是必不可少的。- 当你同时使用
-it
时,就表示你希望以交互模式连接到容器的终端。当你执行
docker run -it alpine
后:
- Docker 启动了一个新的
alpine
容器。- 因为你没有指定要运行的命令,容器会执行
alpine
镜像的默认命令,也就是前面docker inspect
看到的[/bin/sh]
。-it
选项让你直接连接到这个/bin/sh
进程的输入和输出。- 所以你看到了
/ #
这样的提示符,这表示你已经进入了alpine
容器内部的 shell 环境。
后台运行容器
run -it
命令会启动一个交互式终端,退出终端后容器也会停止,如果希望容器在后台运行,可以使用 -d
参数,如下命令会启动一个后台运行的容器。
1 | docker run -it -d alpine #后台运行容器 |
-d
(--detach
):让容器在后台运行。执行完这条命令后,它会立刻返回容器的完整 ID,而不会像上次一样把你的终端“占住”。
加上 -d 参数后,容器不会执行完命令后立即退出,而是会进入后台运行,此时会返回一个唯一的 ID,使用 docker ps
命令可以查看容器运行状态。
docker attach 用于连接到一个正在运行的容器,主要作用是 访问容器的主进程(PID=1)的标准输入输出流
1 | docker attach <container_id> |
由于 attach 是接管了 PID=1 的进程,因此如果这个进程是守护进程,
那么 attach 退出后,容器也会退出。所以一般不推荐使用 attach 命令。
而是使用 docker exec
命令来连接容器。
1 | docker exec -it <container_id> /bin/sh |
此时进入到容器中使用ps -a
命令可以看到容器中存在两个进程,其中 PID=1 的进程为 /bin/sh,
而另一个 /bin/sh 进程则是我们通过 exec 命令启动的,这个进程退出不会影响 PID=1 的进程,也就不会导致容器的退出。
docker attach
:这个命令的作用是,将你当前的终端“附着”到容器正在运行的主进程上。
主进程是什么? 就是
docker ps
中COMMAND
列显示的那个进程,在这里是/bin/sh
。效果:你成功进入了容器的 shell (
/ #
)。这时,你的终端就完全等同于容器里的那个主进程的终端。关键点:你在这里的任何操作,都直接影响主进程。当你从这个 shell 退出时(比如输入
exit
或按下Ctrl+D
),你实际上是**结束了容器的主进程/bin/sh
**。容器停止与
exec
失败
当你从
attach
的 shell 退出后,friendly_tesla
容器的主进程结束了。Docker 的核心原则:容器的生命周期与主进程绑定。主进程一旦退出,容器就会停止。
所以,当你执行
docker ps
时,发现friendly_tesla
已经不见了(因为它停止了,需要docker ps -a
才能看到)。紧接着你尝试
docker exec ...
,系统返回错误:Error response from daemon: container ... is not running
。这个提示非常直白:容器已经没在运行了,所以你无法在里面执行任何新命令。
docker exec
:这个命令的作用是,在一个正在运行的容器内,启动一个新的进程。
1715...
:是你要操作的容器 ID (sad_dubinsky
)。
/bin/sh
:是你希望在容器内启动的那个新进程。
-it
:同样,为这个新启动的/bin/sh
进程分配一个交互式终端。关键点:你通过
exec
进入的这个 shell,是独立于容器主进程(那个由run
命令启动的/bin/sh
)的一个全新的进程。当你从这个 shell 里
exit
或Ctrl+D
退出时,你只是结束了exec
创建的这个新进程。容器的主进程完全不受影响,它还在后台安然无恙地运行着。容器持续运行
- 因此,在你退出
exec
的 shell 后,再次执行docker ps
,会发现sad_dubinsky
依然在Up
状态。
自定义镜像及Dockerfile
Docker 生态之所以如此繁荣,是因为有许许多多的组织或者开发者贡献了大量的功能不同的镜像,这些镜像被用在各种场景中,比如软件分发,CI/CD,云原生应用部署,可观测性等等。
我们将从最简单的镜像创建方式开始,只需将commit一个容器实例作为镜像即可。然后,我们将探索一种更强大、更实用的镜像创建方法:Dockerfile。
从容器创建镜像
首先使用交互式运行一个 alpina 容器。
1 | docker run -it alpine |
然后我们在容器中执行一些命令,比如安装一个软件,然后退出容器。
1 | apk update |
这样,我们就在 alpina 容器中安装了 figlet 工具,当然,通过我们会安装一些更加有用的软件,
比如 git,nginx 等等。然后我们需要将这个新的容器环境跟其他人分享,我们可以通过 commit 命令将容器保存为一个镜像。
1 | docker ps -a #查看容器 |
这样,我们就创建了一个装有 figlet 的镜像,我们可以通过 docker images 命令查看。
1 | docker image ls |
从上一个命令中,获取新创建镜像的 ID,将其重新 tag 为 alpine-figlet。
1 | docker tag <image_id> alpine-figlet |
然后我们就可以使用这个新的镜像了。
1 | docker run alpine-figlet figlet "hello docker" |
最后我们可以也可以使用docker push
命令将镜像推送到镜像仓库中,其他人遍可以使用 docker pull
来使用这个镜像了。
Dockerfile详解
上述从容器创建镜像的方式虽然简单易懂,但是如果涉及版本迭代的时候,
比如下次我需要再额外安装一个 git 命令,就需要重新 commit 一个容器,然后重新 tag 一个镜像,
这样比较麻烦,而且容易出错。因此,我们需要一种更加灵活的镜像创建方式,这就是 Dockerfile。
我们来使用 Dockerfile
来完成上述的同样的事情:
1 | # Dockerfile |
\
单纯用来换行
最后使用 docker build
命令来构建镜像。
1 | docker build -t alpine-figlet-from-dockerfile . |
同样可以使用这个镜像
1 | docker run alpine-figlet-from-dockerfile figlet "hello docker" |
这样当我们需要安装 git 的时候,只需要修改 Dockerfile 中的命令后重新构建镜像即可。
1 | docker build -t alpine-figlet-from-dockerfile . |
-t
是--tag
的缩写,它的作用是为新构建的镜像打上标签(Tag)。
使用 Dockerfile 构建一个 jupyter notebook 镜像
接下来让我们使用 Docker 来构建一个真实可用的镜像,比如 jupyter notebook 镜像。Dockerfile
1 | FROM python:3.10-slim |
1 | docker build -t jupyter-sample jupyter_sample/ |
该镜像使用 RUN 指令来安装 jupyter notebook,使用 WORKDIR指令设置工作目录,
使用COPY指令将代码复制到镜像中,使用 EXPOSE 指令来暴露端口,
最后使用 CMD 指令来启动 jupyter notebook 服务。
使用上述镜像来启动 jupyter notebook 服务。
1 | docker run -d -p 8888:8888 jupyter-sample |
我们使用了 -p 参数来将容器内的 8888 端口映射到宿主机的 8888 端口,在 cnb 上我们可以通过添加一个端口映射来实现外网访问。
点击这个浏览器图标,就可以访问 jupyter notebook 服务了。
使用多阶段构建来打包一个 golang 应用
在实际开发中,我们经常需要构建 golang 应用。
如果使用传统的单阶段构建,最终的镜像会包含整个 Go 开发环境,导致镜像体积非常大。
通过多阶段构建,我们可以创建一个非常小的生产镜像。
创建一个 main.go 文件,
一个普通构建的 Dockerfile
一级一个多阶段构建的 Dockerfile
main.go
1 | module golang_sample |
普通Dockerfile
1 | FROM golang:1.23 |
多阶段Dockerfile
1 | # 第一阶段:构建阶段 |
构建镜像:
1 | docker build -t golang-demo-single -f golang_sample/Dockerfile.single golang_sample/ |
运行容器:
1 | docker run -d -p 8080:8080 golang-demo-single |
容器运行成功后可以通过如下命令行来访问,可以看到两个容器都是在运行我们写的 golang 服务。
1 | curl http://localhost:8080 |
让我们来对比一下单阶段构建和多阶段构建的区别:
1 | 查看镜像大小 |
你会发现最终的镜像只有几十 MB,而如果使用单阶段构建(直接使用 golang 镜像),镜像大小会超过 1GB。这就是多阶段构建的优势:
- 最终镜像只包含运行时必需的文件
- 不包含源代码和构建工具,提高了安全性
- 大大减小了镜像体积,节省存储空间和网络带宽
这种构建方式特别适合 Go 应用,因为 Go 可以编译成单一的静态二进制文件。在实际开发中,我们可以使用这种方式来构建和部署高效的容器化 Go 应用。
实践练习
练习 1:基础镜像和命令使用
1 | # 练习 1:基础镜像和命令使用 |
apt-get update
: 更新包列表,确保能安装到最新的软件版本。
apt-get install -y ...
: 安装软件包。-y
表示自动回答 “yes”,避免在构建过程中需要人工交互。--no-install-recommends
是一个好习惯,它告诉apt
只安装核心依赖,不安装推荐的附加包,可以显著减小镜像体积。
&&
: 这是 shell 的语法,表示当前一个命令成功执行后,再执行下一个。
rm -rf /var/lib/apt/lists/*
: 这是非常关键的一步!apt-get update
会下载很多包列表文件,这些文件在安装完成后就不再需要了。在同一个RUN
指令中将它们删除,可以确保这些临时文件不会被包含到最终的镜像层里,从而有效减小镜像体积。如果分开写成两个RUN
,由于分层机制,删除操作也无法减小之前层占用的空间。
无注释版本
1 | # 练习 1:基础镜像和命令使用 |
练习 2:Python 应用构建
requirments.txt
1 | Flask==3.0.0 |
app.py
1 | from flask import Flask, jsonify |
dockerfile
1 | # 练习 2:Python 应用构建 |
无注释版本
1 | # 练习 2:Python 应用构建 |
生产环境 vs 开发环境:
CMD ["flask", "run"]
启动的是 Flask 自带的开发服务器,它性能不高且功能有限,绝对不应该用于生产环境。在生产环境中,你应该使用一个生产级的 WSGI 服务器,比如 Gunicorn。届时你的CMD
会变成类似这样:
1
2
3 # requirements.txt 中需要添加 gunicorn
# pip install gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
.dockerignore
文件:在项目根目录(和 Dockerfile 同级)创建一个.dockerignore
文件是至关重要的好习惯。它可以防止你把不必要的文件(如本地配置文件、测试数据、.git
目录、__pycache__
等)复制到镜像中,保持镜像的干净和轻量。 示例.dockerignore
文件:
1
2
3
4
5
6 .git
.vscode
__pycache__/
*.pyc
.env
venv/
练习 3:Rust 多阶段构建
Cargo.toml
1 | [package] |
src/main.rs
1 | use chrono::Local; |
dockerfile
1 | # 练习 3:Rust 多阶段构建 |
无注释初始版本
1 | # 练习 3:Rust 多阶段构建 |
总结
为什么有时候创建用户的指令不一样?
【场景描述】 在不同的 Dockerfile 中,我们看到了两种用于创建非 root 用户的命令:一种使用长参数(--system
, --ingroup
),另一种使用短参数(-S
, -G
)。
【目标总结】 理解这两种命令写法的区别,以及应该在何种情况下使用哪一种。
命令的不同,取决于你的 FROM
指令用了哪个基础镜像。
RUN addgroup --system ... && adduser --system ...
- 适用于:Debian 系的发行版,例如
ubuntu
,debian
,以及许多官方应用镜像的slim
版本(如python:3.11-slim
,node:18-slim
),因为它们通常基于 Debian 构建。 - 工具来源:这是 Debian/Ubuntu 中
adduser
工具包的标准用法。
- 适用于:Debian 系的发行版,例如
RUN addgroup -S ... && adduser -S ...
- 适用于:Alpine Linux,例如
alpine:latest
,以及许多以-alpine
结尾的镜像(如rust:1.75-alpine
,nginx:alpine
)。 - 工具来源:这是 Alpine Linux 中由 BusyBox 提供的
adduser
和addgroup
工具的用法。
- 适用于:Alpine Linux,例如
如何选择?
- 看你的
FROM
行! 这是唯一的判断标准。 FROM ubuntu:22.04
-> 用--system
写法。FROM python:3.11-slim
-> 基于 Debian,用--system
写法。FROM alpine:latest
-> 用-S
写法。FROM rust:1.75-alpine
-> 用-S
写法。
- 看你的
如果用错了会怎样?
docker build
会直接失败。如果你在 Alpine 镜像里使用--system
,它会报错说这是一个无法识别的参数。反之亦然。
所以,你不需要死记硬背,只需要养成一个习惯:在写创建用户的指令前,先确认一下基础镜像是什么,然后选用与之匹配的命令语法。
如何找到运行失败的原因?
排查方法:docker run
-> docker ps
(空的) -> docker ps -a
(发现 Exited) -> docker logs 容器id