认识 Docker

Derek Wang15 分钟阅读

用一个虚构的五服务商店,从 Dockerfile 走到运行中的进程:Image 和 Container 的区别、Compose 怎样组织服务、数据什么时候会丢,以及 docker run 背后的 containerd、runc 和 Linux 内核。

第一次跑 docker compose up,终端里会刷出 Built、Created、Started、Healthy,Image 和 Container 两个词混在一起出现。很多困惑都来自把它们当成一回事:build 并不启动程序,删掉 Container 也不一定丢数据。

常见的疑问大概是这几个:

  • 为什么既有 Image,又有 Container?
  • Building Image 是不是在“启动容器”?
  • 为什么一个项目会出现很多容器?
  • 删除 Container 后,数据库数据会不会一起消失?

下面从一个虚构项目出发,把从 Dockerfile 到运行中进程的整条链路走一遍,最后用几条命令亲手验证数据存在哪里。

本文中的 Maple Shop 是专门用于讲解的虚构项目,不对应任何真实仓库、公司或线上系统。

“在我电脑上能跑”:Docker 要解决的问题

假设我们写了一个 Java 应用。它在自己的电脑上运行正常,但交给同事或部署到服务器后,可能出现这些问题:

  • 对方没有安装 Java 21;
  • 系统里安装的是另一个 Java 版本;
  • 缺少配置文件或系统依赖;
  • 启动命令不同;
  • 两个应用依赖的软件版本互相冲突。

Docker 的做法,是把应用以及它运行所需的用户空间环境整理成一份可重复使用的模板,再用这份模板启动隔离的进程环境。

最短的心智模型是:

Text
项目代码 + Dockerfile
          │
          │ docker build
          ▼
       Image
   只读的运行模板
          │
          │ docker create / docker start
          ▼
      Container
  正在运行或已停止的实例

因此:

  • build 负责制作 Image;
  • create 负责从 Image 创建 Container;
  • start 负责启动已经创建的 Container;
  • run 通常等于 create + start。

docker run 在本地找不到指定 Image 时可以先从 Registry 下载,但它不会根据 Dockerfile 自动执行 docker build。

示例项目 Maple Shop:五个服务,五个 Container

Maple Shop 是一个小型网上商店,由五个职责不同的服务组成:

服务职责示例技术
web展示商品和订单页面React + Nginx
api处理业务请求Java 21
db保存商品、用户和订单PostgreSQL
files保存商品图片MinIO
worker异步生成报表、发送通知Node.js

它们共同组成一个项目,但并不是同一个程序:

Text
浏览器
  │
  ▼
web ─────► api ─────► db
             │
             ├──────► files
             │
             └──────► worker(通过任务队列或任务表协作)

在 Docker Compose 中,通常一个服务对应一个 Container,所以这个示例会启动五个 Container。这样做便于独立更新、重启、限制资源和查看日志。

这不是“一个项目只能有一个容器”,也不是“每个容器只能有一个进程”。更准确地说:

Container 是一个隔离的进程环境;一个 Container 可以有多个进程,但它的生命周期通常由 PID 1 进程维持。

Dockerfile、Image、Container:菜谱、模板和实例

对象它是什么是否正在运行是否可复用
Dockerfile制作运行环境的步骤清单否是
Imagebuild 得到的只读模板否是
Container从 Image 创建的运行实例可以运行,也可以停止通常不作为模板复用

可以把它们类比成:

Text
Dockerfile = 菜谱
Image      = 按菜谱准备好的标准套餐模板
Container  = 端到某位顾客桌上的一份具体套餐

同一个 Image 可以创建很多 Container:

Text
maple-api:1.0 Image
       │
       ├──► maple-api-1 Container
       ├──► maple-api-2 Container
       └──► maple-api-test Container

这些 Container 共用相同的只读 Image 层,但拥有各自独立的可写层、进程、网络身份和运行状态。

用四行 Dockerfile 描述 api 的 Image

假设 api 已经被编译成 app.jar,可以使用下面的 Dockerfile:

dockerfile
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

逐行理解:

  1. FROM:选择包含 Java 21 运行环境的基础 Image;
  2. WORKDIR:把后续命令的工作目录设为 /app;
  3. COPY:把宿主机上的 app.jar 放进 Image;
  4. ENTRYPOINT:规定 Container 启动时默认执行的程序。

执行:

Bash
docker build -t maple-api:1.0 .

其中:

  • docker build 表示开始构建;
  • -t maple-api:1.0 给 Image 设置名称和标签;
  • 最后的 . 是 build context,也就是构建时允许 Docker 读取的文件范围。

docker build 不启动程序,它在制作只读的分层模板

构建 Image 并不是启动业务程序。BuildKit 会根据 Dockerfile 逐步制作文件系统层,并记录启动配置等元数据。

可以把构建结果简化成:

Text
Image: maple-api:1.0

┌──────────────────────────────┐
│ 元数据: 入口命令、环境变量等     │
├──────────────────────────────┤
│ COPY app.jar                 │  ← 应用层
├──────────────────────────────┤
│ Java 21 JRE                  │  ← 运行时层
├──────────────────────────────┤
│ 基础 Linux 用户空间文件         │  ← 基础层
└──────────────────────────────┘

Image 的几个关键特点:

Image 是只读模板

构建完成后,这些层作为只读内容被复用。Container 运行时产生的临时修改不会直接写回原 Image。

Image 由多层组成

Dockerfile 中会改变文件系统的步骤通常会形成新的层。不同 Image 可以共享相同的基础层,减少存储和下载量。

BuildKit 会复用缓存

如果某一步的输入没有变化,BuildKit 可以直接使用上次的结果。通常把变化较少的依赖安装放在前面,把经常变化的业务代码放在后面,会获得更好的缓存效果。

上面的 app.jar 已在镜像外编译。为了观察依赖缓存,另取一个在镜像内构建前端的例子,对比两种指令顺序:

Text
A:COPY . . → RUN npm ci → RUN npm run build
B:COPY package*.json ./ → RUN npm ci → COPY . . → RUN npm run build
缓存失效后,哪些工作需要重做?教学模型 · 非实测

两种顺序都要安装依赖并编译。

先复制全部文件2 条 RUN
先复制依赖清单2 条 RUN

按上面两种顺序推导,只统计 npm ci 与 npm run build 两条 RUN 是否执行。假设基础镜像和命令不变、node_modules 与 dist 已排除在构建上下文之外、依赖安装不读取应用源码。步骤数不是耗时,也不代表固定的提速倍数。

推导依据:Docker 缓存失效规则。某一步失效后,其后续步骤也需要重新构建。

Image 通常不包含独立内核

Image 可以包含 Linux 用户空间文件和工具,但 Container 运行时共享宿主 Linux 内核。它不是一台完整虚拟机的磁盘快照。

在 macOS 或 Windows 上使用 Docker Desktop 时,Linux Container 实际共享的是 Docker Desktop 管理的轻量 Linux 虚拟机内核,而不是 macOS 或 Windows 内核。

从 Image 到 Container:create、start 与 PID 1

下面两种写法表达的是同一条主链路。

分两步执行:

Bash
docker create --name maple-api-1 maple-api:1.0
docker start maple-api-1

合并执行:

Bash
docker run --name maple-api-1 maple-api:1.0

创建 Container 时,Docker 会在 Image 之外补充运行时状态:

Text
只读 Image 层
      +
Container 独立可写层
      +
环境变量、挂载、网络配置、资源限制
      +
启动进程(通常成为 Container 内的 PID 1)

对于刚才的 Java 示例,启动后最重要的进程是:

Text
PID 1: java -jar app.jar

当 PID 1 退出时,Container 通常也会进入停止状态。Container 停止不代表它立刻被删除;可以再次 docker start,也可以用 docker rm 删除。

Container 共享宿主内核,不是小型虚拟机

Container 与虚拟机都能提供隔离,但实现方式不同。

对比项Container虚拟机
内核与宿主 Linux 系统共享每台虚拟机有自己的 Guest OS 内核
启动对象隔离的进程环境完整操作系统
启动速度通常更快通常更慢
体积通常较小通常较大
隔离边界namespace、cgroup 等Hypervisor 提供的虚拟硬件边界

Linux 上,Docker 主要依赖两类内核能力:

  • namespace:让进程看到独立的 PID、网络、挂载点、主机名等视图;
  • cgroup:统计并限制 CPU、内存等资源。

所以 Container 的本质更接近“被隔离和限制的一组进程”,而不是“删减版虚拟机”。

删除 Container 后数据去哪了:可写层、Volume 与 Bind Mount

理解 Docker 数据问题时,先分清三种来源:

Text
┌─────────────────────────────────────────┐
│ Image 只读层                             │
│ 程序、运行时、Image 中预置的配置            │
├─────────────────────────────────────────┤
│ Container 可写层                         │
│ 日志、缓存、运行期间临时修改                 │
├─────────────────────────────────────────┤
│ Volume / Bind Mount                     │
│ 需要独立于 Container 生命周期保存的数据      │
└─────────────────────────────────────────┘

Container 可写层适合临时变化

程序运行时可以写文件,但这些变化属于这个具体 Container。删除 Container 时,它的可写层也会被删除。

Volume 适合持久数据

数据库数据不应该只放在 Container 可写层。可以挂载一个命名 Volume:

YAML
services:
  db:
    image: postgres:16
    volumes:
      - maple_db_data:/var/lib/postgresql/data

volumes:
  maple_db_data:

这里的 /var/lib/postgresql/data 是 PostgreSQL 在 Container 内写数据的位置,maple_db_data 则由 Docker 在 Container 外管理。

因此通常有:

Text
删除并重建 db Container
             │
             ▼
重新挂载同一个 maple_db_data Volume
             │
             ▼
原数据库文件仍然存在

docker compose down 默认不会删除命名 Volume;docker compose down -v 会请求一并删除,应谨慎使用。

Bind Mount 适合直接映射宿主目录

开发时常把本地源代码目录映射进 Container:

YAML
services:
  web:
    volumes:
      - ./web:/app

这叫 Bind Mount。它直接对应宿主机路径,更适合本地开发或需要明确访问宿主文件的场景。

Container 之间用服务名通信,localhost 指的是自己

Docker Compose 通常会为同一个项目创建私有网络。服务可以通过服务名相互访问:

Text
api Container ─────► db:5432
api Container ─────► files:9000
web Container ─────► api:8080

这里的 db、files 和 api 类似内部 DNS 名称。

一个非常常见的误区是:

在 api Container 里,localhost 指向 api Container 自己,不是 db Container,也不是宿主机。

所以数据库连接地址应该类似:

Text
jdbc:postgresql://db:5432/shop

而不是:

Text
jdbc:postgresql://localhost:5432/shop

端口映射解决什么问题

Container 之间可以直接使用内部端口;宿主机或浏览器需要访问 Container 时,才通常需要发布端口:

YAML
services:
  api:
    ports:
      - "8080:8080"

格式是:

Text
宿主机端口:Container 端口

此时宿主机访问 localhost:8080,流量会被转发到 api Container 的 8080 端口。

用 Compose 启动五个服务:三次 build,五个 Container

如果手动为五个服务逐一输入 docker run,命令会很长,也很难保持团队成员的配置一致。Compose 文件把这些关系集中写下来。

下面是一份教学用的简化配置:

YAML
services:
  web:
    build: ./web
    ports:
      - "3000:80"
    depends_on:
      - api

  api:
    build: ./api
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: jdbc:postgresql://db:5432/shop
      FILES_ENDPOINT: http://files:9000
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: local-demo-only
    volumes:
      - maple_db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
      interval: 5s
      timeout: 3s
      retries: 10

  files:
    image: minio/minio
    command: server /data

  worker:
    build: ./worker
    environment:
      DATABASE_HOST: db
      DATABASE_PORT: 5432
    depends_on:
      db:
        condition: service_healthy

volumes:
  maple_db_data:

注意两种来源:

  • build: ./api 表示根据本地 Dockerfile 构建新 Image;
  • image: postgres:16 表示直接使用指定 Image,本地没有时通常从 Registry 下载。

执行:

Bash
docker compose up --build

可以把主要过程理解为:

Text
1. 读取 compose.yaml
2. 构建 web、api、worker 三个 Image
3. 拉取本地尚不存在的 postgres、minio Image
4. 创建项目网络和需要的 Volume
5. 从各自 Image 创建五个 Container
6. 启动五个 Container
7. 持续汇总它们的日志和状态

Compose 是编排者,不会把五个服务“合并成一个大 Container”。

要分清这里的两类结果:web、api、worker 构建出三个 Image,它们是可复用的只读模板;web、api、db、files、worker 启动五个 Container,它们才是运行实例。真实输出会随 Compose 版本、缓存和现有资源变化,网络、Volume、拉取 Image 的操作也可能单独出现,看每一行时先看它前面的资源类型和动作。

Started 不等于可用:Built、Started、Healthy 的区别

状态表示什么能否证明业务可用
BuiltImage 构建成功不能,程序还可能没有启动
CreatedContainer 已创建不能,进程还可能未运行
StartedContainer 的启动命令已执行不能完全证明
Healthy配置的 healthcheck 连续通过比 Started 更有意义

Started 只说明进程已经被启动。应用可能还在执行数据库迁移,甚至可能几秒后崩溃。

Healthy 依赖 Compose 或 Image 中定义的 healthcheck。如果没有 healthcheck,就不会凭空出现可信的健康状态。

另外,简单的 depends_on: [db] 主要控制启动顺序,不保证数据库已经可以接受连接。需要等待就绪时,可以结合 condition: service_healthy、重试机制或应用自身的容错逻辑。

一条 docker run 背后:CLI、dockerd、containerd、runc 与内核

日常使用时,我们输入的是 Docker 命令;背后还有多层组件协作:

Text
docker CLI
    │
    ▼
Docker Engine / dockerd
    │
    ├── 构建 Image ──► BuildKit
    │
    └── 管理 Container ──► containerd
                              │
                              ▼
                             runc
                              │
                              ▼
                  Linux namespace + cgroup
                              │
                              ▼
                         隔离的进程

简化理解:

  • Docker CLI:接收 docker build、docker run 等命令;
  • Docker Engine:管理 Image、Container、网络和 Volume;
  • BuildKit:负责高效构建 Image;
  • containerd:管理 Container 生命周期和 Image 内容;
  • runc:按照 OCI 配置创建底层 Container 进程;
  • Linux 内核:真正提供进程隔离和资源限制能力。

这也是为什么 Docker 不只是“把一个程序打包成压缩文件”。它既涉及构建产物,也涉及运行时隔离、网络、存储和生命周期管理。

Docker 常用命令速查

命令作用
docker build -t maple-api:1.0 .根据 Dockerfile 构建 Image
docker pull postgres:16从 Registry 下载 Image
docker images查看本地 Image
docker create IMAGE从 Image 创建但不启动 Container
docker start NAME启动已有 Container
docker run IMAGE创建并启动 Container
docker ps查看正在运行的 Container
docker ps -a查看运行中和已停止的 Container
docker logs -f NAME持续查看 Container 日志
docker exec -it NAME sh在运行中的 Container 里执行命令
docker stop NAME请求停止 Container
docker rm NAME删除已停止的 Container
docker compose up --build构建并启动 Compose 项目
docker compose down停止并删除 Compose 的 Container 和网络
docker compose down -v在 down 基础上删除命名 Volume

Docker 新手常见的七个误区

误区一:build 就是把 Image 实例化成 Container

不是。build 的终点是 Image;create 才负责创建 Container,start 才负责启动它。

误区二:Image 是一个停止的 Container

不是。Image 是只读模板,Container 是带有运行时状态和可写层的实例。两者的数据结构和职责不同。

误区三:一个项目只能有一个 Container

不是。项目通常按职责拆分服务,每个服务可以有一个或多个 Container。五个服务出现五个 Container 很正常。

误区四:进入 Container 后,localhost 就是自己的电脑

不是。localhost 总是指当前网络环境本身。在 Container 内,它通常只指这个 Container。

误区五:只要显示 Started,应用就一定可用

不是。Started 只表示启动动作完成;应继续检查日志、healthcheck 和实际接口。

误区六:删除 Container 一定会丢失所有数据

不一定。Container 可写层会丢失;挂载在命名 Volume 或 Bind Mount 中的数据可以独立保留。

误区七:使用 Docker 就不需要理解 Linux

Docker 隐藏了很多重复配置,但底层仍然是进程、端口、文件权限、信号、网络和存储。理解这些基础概念会让排错容易很多。

动手验证:可写层和 Volume 的区别

读到这里,可以用几条命令亲手验证“删除 Container 会不会丢数据”。下面用很小的 alpine Image,本地没有时 Docker 会先下载:

Bash
# 在一个 Container 的可写层里写文件,进程结束后 Container 停止但不删除
docker run --name note-demo alpine:3.20 sh -c 'echo hello > /note.txt'

# 查看这个 Container 相对 Image 改了什么,输出里会有 A /note.txt
docker diff note-demo

# 从同一个 Image 新建的 Container 看不到这个文件,会报 No such file or directory
docker run --rm alpine:3.20 cat /note.txt

# 删除 Container,它的可写层也随之删除
docker rm note-demo

docker diff 的输出里还可能出现几行别的变化,取决于你的 Docker 环境,重点看 A /note.txt。

再换成 Volume:

Bash
docker volume create note-data

# 第一个 Container 写入 Volume,结束后被 --rm 自动删除
docker run --rm -v note-data:/data alpine:3.20 sh -c 'echo hello > /data/note.txt'

# 第二个全新的 Container 挂载同一个 Volume,能读到 hello
docker run --rm -v note-data:/data alpine:3.20 cat /data/note.txt

docker volume rm note-data

两次实验用的是同一个 Image,区别只在文件写到了哪里。开头那几个问题,也都能这样拆开来看:Building 产出的是 Image,Started 说的是 Container,数据会不会消失,取决于它在可写层还是 Volume 里。接下来可以把 Maple Shop 的 Compose 文件换成自己项目的服务,跑一次 docker compose up --build,对照输出里的每一行,看它是在处理 Image、Container、网络还是 Volume。

全站搜索

笔记
博客
项目
资源
关于我