Docker前置核心概念

【核心概念解析】

  1. 镜像(Image)
    • 比喻:你可以把镜像想象成一个制作蛋糕的“模具”,或者一个电脑的“操作系统安装包”。它是一个只读的模板,里面包含了运行一个应用程序所需的所有东西:代码、运行时环境(比如 Python、Node.js)、库、环境变量和配置文件。
    • 作用:镜像用于创建容器。你不会直接运行一个镜像,而是基于它“制作”出一个个运行实例。
  2. 容器(Container)
    • 比喻:如果说镜像是一个蛋糕模具,那么容器就是用这个模具做出来的“蛋糕”本身。它是一个独立、可运行的软件包。或者说,它是你从操作系统安装包(镜像)安装出来的一个个独立的“虚拟机实例”,但比虚拟机轻量得多。
    • 作用:容器是应用程序实际运行的地方。每个容器都是相互隔离的,拥有自己的文件系统、进程空间、网络接口等。
  3. Docker 守护进程(Docker Daemon / Server)
    • 比喻:它就像一个“管家”或者“总指挥”。
    • 作用:Docker 守护进程是 Docker 平台的核心组件,它在后台运行,负责构建、运行、分发你的 Docker 镜像和容器。你通过 docker 命令行工具发送的命令,都是先发送给这个守护进程,然后由它来执行。
  4. Docker 客户端(Docker Client)
    • 比喻:它就像你手里的“遥控器”。
    • 作用:你平时在终端输入的 docker 命令就是 Docker 客户端。它负责接收你的命令,并将这些命令发送给 Docker 守护进程去处理。

Docker 基础

cnb 云原生开发环境中已经预装了 docker,无需学员手动安装,直接体验即可,可使用如下命令来查看 docker 信息

1
2
docker version  #查看版本信息
docker info #查看运行时信息
  • version

image-20250625015512348

这条命令的作用是显示你的 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

image-20250625015805593

这条命令会显示关于 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>

image-20250625015855695

这是第一次真正“运行”一个容器。

  • **docker run**:这是一个核心命令,用于 创建并启动 一个新的容器。
  • hello-world:这是你要运行的镜像名称

当你运行这条命令时,Docker 做了这些事情(你的输出中也清晰地说明了):

  1. Unable to find image 'hello-world:latest' locally

    • 解释:Docker 客户端告诉 Docker 守护进程,我想运行 hello-world 镜像。守护进程首先检查本地是否已经有这个镜像。发现本地没有。
    • latest 是什么?latest 是镜像的标签(Tag)。一个镜像可以有多个标签,latest 通常指向最新版本。如果你不指定标签,Docker 默认会使用 latest
  2. latest: Pulling from library/hello-world

    • 解释:由于本地没有,Docker 守护进程会去 Docker Hub(这是一个官方的、公共的 Docker 镜像仓库,你可以理解为镜像的“应用商店”)拉取 hello-world 镜像。
    • **Pulling**:这个动作就是“拉取”,从远程仓库下载镜像到本地。
  3. e6590344b1a5: Pull complete

    • 解释:镜像是由多层(layer)组成的,每一层都有一个唯一的 ID。这里显示其中一层已经下载完成。这种分层机制使得镜像共享和更新非常高效。
  4. Status: Downloaded newer image for hello-world:latest

    • 解释:镜像已经成功下载到你的本地了。
  5. 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
2
docker pull alpine  #拉取镜像
docker image ls #查看镜像

image-20250625020003823

  • docker pull:这个命令专门用来从远程仓库(默认是 Docker Hub)拉取(下载)镜像到你的本地。

  • **alpine**:要拉取的镜像名称。

这与 docker run hello-world 中的下载步骤类似,但 docker pull 只负责下载镜像,不立即运行容器。当你只是想预先下载好某个镜像,以便后续使用时,会用到这个命令。

  • **docker image ls**:这个命令用来查看你本地已经下载的所有 Docker 镜像。

输出的每一列的含义:

  • REPOSITORY:镜像的仓库名称,比如 alpinehello-world
  • TAG:镜像的标签,通常表示版本号,比如 latest
  • IMAGE ID:镜像的唯一 ID。它是镜像内容的哈希值,每个镜像 ID 都是独一无二的。
  • CREATED:镜像创建的时间。
  • SIZE:镜像的大小。你会发现 alpine 镜像非常小(8.3MB),这是一个非常轻量级的 Linux 发行版,非常适合 Docker。hello-world 只有 10.1KB,因为它只包含一个简单的执行文件。

运行容器

1
2
docker run alpine ls -a  #运行容器
docker ps -a #查看容器

image-20250625020141691

docker run alpine ls -a

  • 基于 alpine 镜像运行一个容器,并在容器内执行 ls -a 命令。

  • 这条命令结合了 docker run 和一个具体的命令 ls -a

    • **docker run alpine**:同样是创建并启动一个基于 alpine 镜像的容器。
    • **ls -a**:这是你希望在这个新容器中执行的命令。ls -a 是 Linux 命令,用于列出当前目录下的所有文件和文件夹(包括隐藏文件)。

    这个命令的背后逻辑

    1. Docker 根据 alpine 镜像创建并启动了一个全新的容器。
    2. 在这个容器内部,它执行了你指定的 ls -a 命令。
    3. ls -a 命令的输出(也就是那些 ., .., bin, dev 等目录)被返回并显示在你的终端上。
    4. 因为 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,然后就停止了容器。

image-20250625020333117

  • **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 参数启动容器进入交互式终端

image-20250625020539663

  • **docker run alpine**:仍然是创建并启动一个基于 alpine 镜像的容器。
  • -i--interactive:这个选项表示交互模式。它会保持标准输入 (STDIN) 打开,即使没有附加到容器。这意味着你可以向容器输入命令。
  • -t--tty:这个选项会为容器分配一个伪终端(pseudo-TTY)。这使得容器的输出可以像在普通终端上一样显示,对于交互式会话是必不可少的。
  • 当你同时使用 -it 时,就表示你希望以交互模式连接到容器的终端。

当你执行 docker run -it alpine 后:

  1. Docker 启动了一个新的 alpine 容器。
  2. 因为你没有指定要运行的命令,容器会执行 alpine 镜像的默认命令,也就是前面 docker inspect 看到的 [/bin/sh]
  3. -it 选项让你直接连接到这个 /bin/sh 进程的输入和输出。
  4. 所以你看到了 / # 这样的提示符,这表示你已经进入了 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 的进程,也就不会导致容器的退出。

image-20250625020746824

  • docker attach:这个命令的作用是,将你当前的终端“附着”到容器正在运行的主进程上

    • 主进程是什么? 就是 docker psCOMMAND 列显示的那个进程,在这里是 /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 里 exitCtrl+D 退出时,你只是结束了 exec 创建的这个新进程。容器的主进程完全不受影响,它还在后台安然无恙地运行着。

  • 容器持续运行

    • 因此,在你退出 exec 的 shell 后,再次执行 docker ps,会发现 sad_dubinsky 依然在 Up 状态。

自定义镜像及Dockerfile

Docker 生态之所以如此繁荣,是因为有许许多多的组织或者开发者贡献了大量的功能不同的镜像,这些镜像被用在各种场景中,比如软件分发,CI/CD,云原生应用部署,可观测性等等。

我们将从最简单的镜像创建方式开始,只需将commit一个容器实例作为镜像即可。然后,我们将探索一种更强大、更实用的镜像创建方法:Dockerfile。

从容器创建镜像

首先使用交互式运行一个 alpina 容器。

1
docker run -it alpine

然后我们在容器中执行一些命令,比如安装一个软件,然后退出容器。

1
2
3
4
apk update
apk add figlet
figlet "hello docker"
exit

这样,我们就在 alpina 容器中安装了 figlet 工具,当然,通过我们会安装一些更加有用的软件,
比如 git,nginx 等等。然后我们需要将这个新的容器环境跟其他人分享,我们可以通过 commit 命令将容器保存为一个镜像。

1
2
docker ps -a #查看容器
docker commit <container_id>

这样,我们就创建了一个装有 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 来使用这个镜像了。

image-20250625021621362

Dockerfile详解

上述从容器创建镜像的方式虽然简单易懂,但是如果涉及版本迭代的时候,
比如下次我需要再额外安装一个 git 命令,就需要重新 commit 一个容器,然后重新 tag 一个镜像,
这样比较麻烦,而且容易出错。因此,我们需要一种更加灵活的镜像创建方式,这就是 Dockerfile。
我们来使用 Dockerfile 来完成上述的同样的事情:

1
2
3
4
5
# Dockerfile
FROM alpine:latest
RUN apk update &&\
apk add figlet &&\
apk add git

\单纯用来换行

最后使用 docker build 命令来构建镜像。

1
docker build -t alpine-figlet-from-dockerfile .

同样可以使用这个镜像

1
docker run alpine-figlet-from-dockerfile figlet "hello docker"

这样当我们需要安装 git 的时候,只需要修改 Dockerfile 中的命令后重新构建镜像即可。

1
2
docker build -t alpine-figlet-from-dockerfile .
docker run alpine-figlet-from-dockerfile git

-t--tag 的缩写,它的作用是为新构建的镜像打上标签(Tag)

使用 Dockerfile 构建一个 jupyter notebook 镜像

接下来让我们使用 Docker 来构建一个真实可用的镜像,比如 jupyter notebook 镜像。Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
FROM python:3.10-slim

# 安装系统依赖
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*

# 安装 Python 包
RUN pip install --no-cache-dir \
jupyterlab==4.4.3 \
pandas==2.3.0 \
numpy==1.26.2 \
matplotlib==3.8.2

# 安装 Jupyter 内核
RUN pip install --no-cache-dir \
ipykernel==6.29.0 \
&& python -m ipykernel install --name python3

# 设置工作目录并初始化笔记本
WORKDIR /notebooks
COPY sample-notebook.ipynb .

# 暴露 Jupyter 端口
EXPOSE 8888

# 启动命令
CMD ["jupyter", "lab", "--ip=0.0.0.0", "--allow-root", "--no-browser", "--NotebookApp.token=''", "--NotebookApp.disable_check_xsrf=True"]

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 上我们可以通过添加一个端口映射来实现外网访问。

image-20250625022022770

点击这个浏览器图标,就可以访问 jupyter notebook 服务了。

使用多阶段构建来打包一个 golang 应用

在实际开发中,我们经常需要构建 golang 应用。
如果使用传统的单阶段构建,最终的镜像会包含整个 Go 开发环境,导致镜像体积非常大。
通过多阶段构建,我们可以创建一个非常小的生产镜像。

创建一个 main.go 文件,
一个普通构建的 Dockerfile
一级一个多阶段构建的 Dockerfile

main.go

1
2
3
module golang_sample

go 1.23.3

普通Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM golang:1.23

# 定义构建参数
ARG PORT=8080
# 设置环境变量
ENV PORT=${PORT}

WORKDIR /app

COPY go.mod .
COPY main.go .

ENV CGO_ENABLED=0
ENV GOOS=linux

RUN go mod tidy && go build -ldflags="-w -s" -o server .

EXPOSE ${PORT}

CMD ["./server"]

多阶段Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 第一阶段:构建阶段
FROM golang:1.23 AS builder

# 设置工作目录
WORKDIR /app

# 将源代码复制到容器中
COPY go.mod .
COPY main.go .

# 设置必要的 Go 环境变量
ENV CGO_ENABLED=0
ENV GOOS=linux

# 编译 Go 应用
RUN go mod tidy && go build -ldflags="-w -s" -o server .

# 第二阶段:运行阶段
FROM alpine:latest

# 定义构建参数
ARG PORT=8081
# 设置环境变量
ENV PORT=${PORT}

# 安装 CA 证书,这在某些需要 HTTPS 请求的应用中可能需要
RUN apk --no-cache add ca-certificates

# 设置工作目录
WORKDIR /root/

# 从 builder 阶段复制编译好的二进制文件
COPY --from=builder /app/server .

# 暴露应用端口
EXPOSE ${PORT}

# 运行应用
CMD ["./server"]

构建镜像:

1
2
docker build -t golang-demo-single -f golang_sample/Dockerfile.single golang_sample/
docker build -t golang-demo-multe -f golang_sample/Dockerfile.multi golang_sample/

运行容器:

1
2
docker run -d -p 8080:8080 golang-demo-single
docker run -d -p 8081:8081 golang-demo-multe

容器运行成功后可以通过如下命令行来访问,可以看到两个容器都是在运行我们写的 golang 服务。

1
2
curl http://localhost:8080
curl http://localhost:8081

让我们来对比一下单阶段构建和多阶段构建的区别:

1
2
# 查看镜像大小
docker images | grep golang-demo

你会发现最终的镜像只有几十 MB,而如果使用单阶段构建(直接使用 golang 镜像),镜像大小会超过 1GB。这就是多阶段构建的优势:

  • 最终镜像只包含运行时必需的文件
  • 不包含源代码和构建工具,提高了安全性
  • 大大减小了镜像体积,节省存储空间和网络带宽

这种构建方式特别适合 Go 应用,因为 Go 可以编译成单一的静态二进制文件。在实际开发中,我们可以使用这种方式来构建和部署高效的容器化 Go 应用。

实践练习

练习 1:基础镜像和命令使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 练习 1:基础镜像和命令使用
#
# 要求:
# 1. 使用 ubuntu:22.04 作为基础镜像
# 2. 安装 nginx 和 curl 包
# 3. 创建一个简单的 HTML 文件,内容为 "Hello Docker!"
# 4. 将 HTML 文件复制到 nginx 的默认网站目录
# 5. 暴露 80 端口
# 6. 启动 nginx 服务
#
# 提示:
# - 使用 apt-get update 和 apt-get install 安装软件包
# - nginx 的默认网站目录是 /var/www/html/
# - 使用 CMD 或 ENTRYPOINT 启动 nginx
#
# 完成这个 Dockerfile 后,构建镜像并运行容器,访问 http://localhost:8080 应该能看到 "Hello Docker!" 页面

FROM ubuntu:22.04

# 在这里编写你的 Dockerfile 指令

# 安装必要的软件包
# RUN 指令用于在镜像内部执行命令。这是构建镜像的核心步骤。
# 为了保持镜像的整洁和减少层数,我们通常将多个命令用 `&& \` 连接起来在单一个 RUN 指令中执行。
RUN apt-get update && \
apt-get install -y --no-install-recommends \
nginx \
curl \
&& rm -rf /var/lib/apt/lists/*

# 创建并写入 HTML 文件
# 这里我们再次使用 RUN 指令,通过 shell 的重定向功能 `>` 创建一个 index.html 文件。
# `echo` 命令输出字符串 "Hello Docker!",然后这个字符串被写入到 Nginx 的默认网站根目录 `/var/www/html/` 下的 `index.html` 文件中。
# 注意:Dockerfile 的指令(如 RUN)是不区分大小写的,但为了规范,通常使用大写。
RUN echo "Hello Docker!" > /var/www/html/index.html

# 声明容器对外暴露的端口
# EXPOSE 指令用于声明容器在运行时会监听的端口。这更像是一个文档性质的说明,告诉使用者这个镜像的服务端口是 80。
# 它本身不会做任何端口映射,真正的端口映射是在 `docker run` 时使用 `-p` 参数完成的。
EXPOSE 80

# 设置容器启动时执行的命令
# CMD 指令用于指定容器启动时要执行的默认命令。
# 这里我们启动 Nginx 服务。`["nginx", "-g", "daemon off;"]` 是 Nginx 官方推荐的前台启动方式。
# 为什么用这种方式?因为 Docker 容器的生命周期与它的主进程绑定,如果 Nginx 以守护进程(后台模式)运行,主进程会立即退出,导致容器也随之停止。
# `daemon off;` 指令强制 Nginx 在前台运行,从而保持容器持续运行。
CMD ["nginx", "-g", "daemon off;"]

# 测试命令:
# docker build -t exercise1 .
# docker run -d -p 8080:80 exercise1
# curl http://127.0.0.1:8080 | grep -q "Hello Docker"

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 练习 1:基础镜像和命令使用
#
# 要求:
# 1. 使用 ubuntu:22.04 作为基础镜像
# 2. 安装 nginx 和 curl 包
# 3. 创建一个简单的 HTML 文件,内容为 "Hello Docker!"
# 4. 将 HTML 文件复制到 nginx 的默认网站目录
# 5. 暴露 80 端口
# 6. 启动 nginx 服务
#
# 提示:
# - 使用 apt-get update 和 apt-get install 安装软件包
# - nginx 的默认网站目录是 /var/www/html/
# - 使用 CMD 或 ENTRYPOINT 启动 nginx
#
# 完成这个 Dockerfile 后,构建镜像并运行容器,访问 http://localhost:8080 应该能看到 "Hello Docker!" 页面

FROM ubuntu:22.04

# 在这里编写你的 Dockerfile 指令
run apt-get update && \
apt-get install -y --no-install-recommends \
nginx curl \
&& rm -rf /var/lib/apt/lists/*

Run echo "Hello Docker" > /var/www/html/index.html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]
# 测试命令:
# docker build -t exercise1 .
# docker run -d -p 8080:80 exercise1
# curl http://127.0.0.1:8080 | grep -q "Hello Docker"

练习 2:Python 应用构建

requirments.txt

1
2
3
4
5
6
Flask==3.0.0
Werkzeug==3.0.1
click==8.1.7
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3

app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from flask import Flask, jsonify
import os

app = Flask(__name__)

@app.route('/')
def hello():
return jsonify({
'message': 'Hello Docker!',
'status': 'success'
})

@app.route('/health')
def health():
return jsonify({
'status': 'healthy',
'version': '1.0.0'
})

if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
app.run(host='0.0.0.0', port=port)

dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# 练习 2:Python 应用构建
#
# 要求:
# 1. 创建一个简单的 Python Flask 应用的 Dockerfile
# 2. 实现以下功能:
# - 使用 python:3.11-slim 作为基础镜像
# - 安装应用依赖
# - 创建非 root 用户运行应用
# - 配置工作目录
# - 设置环境变量
# - 暴露应用端口
#
# 提示:
# - 使用 requirements.txt 管理依赖
# - 使用 WORKDIR 设置工作目录
# - 使用 USER 指令切换用户
# - 使用 ENV 设置环境变量
#
# 测试命令:
# docker build -t exercise2 .
# docker run -d -p 5000:5000 exercise2
# curl http://127.0.0.1:5000 | grep -q "Hello Docker"

# 在这里编写你的 Dockerfile 指令

# --- Dockerfile 开始 ---

# 1. 指定基础镜像
# python:3.11-slim 是一个官方的轻量级镜像,它不包含许多非必需的编译工具和库,
# 体积远小于标准版,非常适合作为应用的运行环境。
FROM python:3.11-slim

# 2. 设置环境变量
# 这是一种良好的实践,将配置与代码分离。
# FLASK_APP: 告诉 Flask 命令要运行哪个文件。
# FLASK_RUN_HOST=0.0.0.0: 让 Flask 服务监听在所有网络接口上,
# 这样 Docker 外部才能通过映射的端口访问到容器内的应用。如果监听默认的 127.0.0.1,则无法从外部访问。
ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0
# ENV PYTHONUNBUFFERED=1

# 3. 创建非 root 用户和用户组
# 出于安全考虑,容器内的应用不应该以 root 用户身份运行。
# --system: 创建一个系统用户,它没有密码,也不会创建 home 目录,非常适合用于运行服务。
# --group: 同时创建一个同名的用户组。
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser

# 简单写法:RUN adduser --system --group myappuser
# 简单写法后赋予权利应该这样子:RUN chown -R myappuser:myappuser /app

# 4. 创建并设置工作目录
# WORKDIR 指令会创建目录(如果不存在),并将其设置为后续指令(如 RUN, COPY, CMD)的默认执行目录。
WORKDIR /app

# 5. 复制并安装依赖(利用层缓存)
# 这是关键的优化步骤!我们先只复制 requirements.txt 文件。
# Docker 在构建时,如果发现这一层的文件(即 requirements.txt)没有变化,
# 就会直接使用缓存,跳过下一条 RUN 指令,从而避免了每次都重新安装依赖。
COPY requirements.txt .

# --no-cache-dir: 告诉 pip 不要缓存下载的包,可以减小镜像体积。
# -r requirements.txt: 从指定文件安装依赖。
RUN pip install --no-cache-dir -r requirements.txt

# 6. 复制应用代码
# 将依赖安装和代码复制分开。这样,只有当我们修改 `app.py` 时,
# Docker 才会重新执行这个 COPY 指令以及之后的层,而不需要重新安装依赖。
COPY app.py .

# 7. 更改工作目录所有权
# 将 /app 目录及其所有文件的所有者更改为我们新创建的 appuser 用户。
RUN chown -R appuser:appgroup /app

# 8. 切换到非 root 用户
# USER 指令将后续指令的执行用户切换为 appuser。
# 从这里开始,包括最后的 CMD,都将以非 root 权限运行,更安全。
USER appuser

# 9. 声明端口
# 告诉使用者容器内的应用监听 5000 端口。
EXPOSE 5000

# 10. 设置容器启动命令
# 使用 Flask 内置的开发服务器启动应用。
CMD ["flask", "run"]

# --- Dockerfile 结束 ---

无注释版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# 练习 2:Python 应用构建
#
# 要求:
# 1. 创建一个简单的 Python Flask 应用的 Dockerfile
# 2. 实现以下功能:
# - 使用 python:3.11-slim 作为基础镜像
# - 安装应用依赖
# - 创建非 root 用户运行应用
# - 配置工作目录
# - 设置环境变量
# - 暴露应用端口
#
# 提示:
# - 使用 requirements.txt 管理依赖
# - 使用 WORKDIR 设置工作目录
# - 使用 USER 指令切换用户
# - 使用 ENV 设置环境变量
#
# 测试命令:
# docker build -t exercise2 .
# docker run -d -p 5000:5000 exercise2
# curl http://127.0.0.1:5000 | grep -q "Hello Docker"

# 在这里编写你的 Dockerfile 指令

FROM python:3.11-slim

ENV FLASK_APP=app.py
ENV FLASK_RUN_HOST=0.0.0.0

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

# ----AI优化----
# 1. 创建非 root 用户
RUN adduser --system --group myappuser

# 2. 赋予权限
RUN chown -R myappuser:myappuser /app

# 3. 切换到非 root 用户
USER myappuser
# -------------
EXPOSE 5000

CMD ["flask", "run"]

生产环境 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
2
3
4
5
6
7
[package]
name = "rust-docker-example"
version = "0.1.0"
edition = "2021"

[dependencies]
chrono = "0.4"

src/main.rs

1
2
3
4
5
6
7
8
9
10
11
use chrono::Local;

fn main() {
// 获取当前时间
let now = Local::now();
let timestamp = now.format("%Y-%m-%d %H:%M:%S").to_string();

// 直接输出到 stdout
println!("Hello from Rust!");
println!("当前时间: {}", timestamp);
}

dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# 练习 3:Rust 多阶段构建
#
# 要求:
# 1. 使用多阶段构建来优化 Rust 应用的 Docker 镜像
# 2. 第一阶段:
# - 使用 rust:1.75-slim 作为基础镜像
# - 设置工作目录
# - 复制 Cargo.toml 和 Cargo.lock(如果存在)
# - 复制源代码
# - 安装 MUSL 目标环境, 支持交叉编译 rustup target add x86_64-unknown-linux-musl
# - 使用 cargo build --target x86_64-unknown-linux-musl --release 构建应用
# 3. 第二阶段:
# - 使用 alpine:latest 作为基础镜像
# - 从第一阶段复制编译好的二进制文件
# - 设置工作目录
# - 运行应用
#
# 提示:
# 1. 使用 COPY --from=builder 从构建阶段复制文件
# 2. 注意文件权限和所有权
# 3. 确保最终镜像尽可能小, 小于 20 M
#
# 测试命令:
# docker build -t rust-exercise3 .
# docker run rust-exercise3

# 在这里编写你的 Dockerfile
# --- 阶段 1: Builder ---
# 这个阶段负责编译我们的 Rust 应用。我们称之为 'builder'。
# 使用 'as builder' 可以给这个阶段命名,方便在后续阶段引用。
FROM rust:1.75-slim as builder

# 1. 安装 MUSL 目标,为静态编译做准备。
# MUSL 是一个轻量级的 C 标准库,与它链接可以生成完全静态的可执行文件。
RUN rustup target add x86_64-unknown-linux-musl

# 2. 创建一个项目目录,并初始化一个临时的 cargo 项目
WORKDIR /app

# 这一步是为了后续能够只编译依赖,而不是整个项目
# RUN cargo init --bin .

# 3. (关键优化) 仅复制依赖配置文件
# 我们先只复制 Cargo.toml 和 Cargo.lock,这两个文件定义了项目的所有依赖。
COPY Cargo.toml Cargo.lock ./

# 4. (关键优化) 只构建依赖
# 因为我们只复制了依赖文件,并且 src/main.rs 是一个空的模板,
# 所以 cargo build 只会下载并编译所有的第三方库 (dependencies)。
# Docker 会将这一层缓存起来。只要 Cargo.toml/Cargo.lock 不变,这一步就不会重新运行!
RUN cargo build --target x86_64-unknown-linux-musl --release

# 5. 复制我们真正的源代码
# 只有在源代码发生变化时,这一层以及之后的缓存才会失效。
COPY src ./src

# 6. 构建真正的应用
# 此时,所有依赖都已经被编译和缓存好了,Cargo 只会编译我们自己的代码,速度会非常快。
RUN cargo build --target x86_64-unknown-linux-musl --release

# --- 阶段 2: Final Image ---
# 这个阶段负责创建最终的、轻量级的运行环境。
FROM alpine:latest

# 1. 创建非 root 用户和用户组,提升安全性。
# -S: 创建系统用户 (system user)
# -G: 指定用户组
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# 2. 设置工作目录
WORKDIR /app

# 3. 从 'builder' 阶段复制编译好的二进制文件
# --from=builder: 指定从哪个阶段复制。
# 我们从 builder 阶段的 release 目录中,复制我们编译好的应用。
# 注意:这里的 'rust-docker-exercise' 必须与 Cargo.toml 中的 [package].name 一致。
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-docker-exercise .

# 4. 更改文件所有权,使其属于我们创建的非 root 用户。
RUN chown appuser:appgroup rust-docker-exercise

# 5. 切换到非 root 用户
USER appuser

# 6. 设置容器启动命令
CMD ["./rust-docker-exercise"]

无注释初始版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 练习 3:Rust 多阶段构建
#
# 要求:
# 1. 使用多阶段构建来优化 Rust 应用的 Docker 镜像
# 2. 第一阶段:
# - 使用 rust:1.75-slim 作为基础镜像
# - 设置工作目录
# - 复制 Cargo.toml 和 Cargo.lock(如果存在)
# - 复制源代码
# - 安装 MUSL 目标环境, 支持交叉编译 rustup target add x86_64-unknown-linux-musl
# - 使用 cargo build --target x86_64-unknown-linux-musl --release 构建应用
# 3. 第二阶段:
# - 使用 alpine:latest 作为基础镜像
# - 从第一阶段复制编译好的二进制文件
# - 设置工作目录
# - 运行应用
#
# 提示:
# 1. 使用 COPY --from=builder 从构建阶段复制文件
# 2. 注意文件权限和所有权
# 3. 确保最终镜像尽可能小, 小于 20 M
#
# 测试命令:
# docker build -t rust-exercise3 .
# docker run rust-exercise3

# 在这里编写你的 Dockerfile

FROM rust:1.75-slim as builder

WORKDIR /app

RUN rustup target add x86_64-unknown-linux-musl

COPY Cargo.toml .
COPY src ./src

RUN cargo build --target x86_64-unknown-linux-musl --release

FROM alpine:latest

# -D: Don't assign a password, -S: Create a system user, -G: Create a group
RUN addgroup -S myappgroup && adduser -S myappuser -G myappgroup

WORKDIR /app

COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rust-docker-example .

RUN chown myappuser:myappgroup rust-docker-example

USER myappuser

CMD ["./rust-docker-example"]

总结

为什么有时候创建用户的指令不一样?

【场景描述】 在不同的 Dockerfile 中,我们看到了两种用于创建非 root 用户的命令:一种使用长参数(--system, --ingroup),另一种使用短参数(-S, -G)。

【目标总结】 理解这两种命令写法的区别,以及应该在何种情况下使用哪一种。

命令的不同,取决于你的 FROM 指令用了哪个基础镜像。

  1. RUN addgroup --system ... && adduser --system ...

    • 适用于:Debian 系的发行版,例如 ubuntu, debian,以及许多官方应用镜像的 slim 版本(如 python:3.11-slim, node:18-slim),因为它们通常基于 Debian 构建。
    • 工具来源:这是 Debian/Ubuntu 中 adduser 工具包的标准用法。
  2. RUN addgroup -S ... && adduser -S ...

    • 适用于:Alpine Linux,例如 alpine:latest,以及许多以 -alpine 结尾的镜像(如 rust:1.75-alpine, nginx:alpine)。
    • 工具来源:这是 Alpine Linux 中由 BusyBox 提供的 adduseraddgroup 工具的用法。
  3. 如何选择?

    • 看你的 FROM 行! 这是唯一的判断标准。
    • FROM ubuntu:22.04 -> 用 --system 写法。
    • FROM python:3.11-slim -> 基于 Debian,用 --system 写法。
    • FROM alpine:latest -> 用 -S 写法。
    • FROM rust:1.75-alpine -> 用 -S 写法。
  4. 如果用错了会怎样?

    • docker build 会直接失败。如果你在 Alpine 镜像里使用 --system,它会报错说这是一个无法识别的参数。反之亦然。

所以,你不需要死记硬背,只需要养成一个习惯:在写创建用户的指令前,先确认一下基础镜像是什么,然后选用与之匹配的命令语法。

如何找到运行失败的原因?

排查方法docker run -> docker ps (空的) -> docker ps -a (发现 Exited) -> docker logs 容器id