3.1 Docker 容器入门

预计阅读时间:18 分钟

📖 目录

学习目标

学完本章后,你将能够:

  • 理解容器与虚拟机的本质区别及 Docker 的核心架构
  • 掌握镜像拉取、容器运行、构建 Dockerfile 的完整流程
  • 使用 Volume 和 Bind Mount 实现数据持久化
  • 用 Docker Compose 编排多容器应用
  • 理解镜像分层与 Union Filesystem 的工作原理

核心知识

  • 容器(Container)——轻量级、可执行的软件包,包含运行所需的一切(代码、运行时、系统工具、库)
  • 镜像(Image)——容器的只读模板,由多层文件系统叠加而成
  • Dockerfile——定义镜像构建步骤的文本文件,每行指令创建一个新层
  • 镜像层(Layer)——Dockerfile 中每条指令(RUN、COPY、ADD)产生的新文件系统快照
  • 容器注册表(Registry)——存储和分发镜像的服务,默认 Docker Hub
  • Volume——由 Docker 管理的持久化数据存储,独立于容器生命周期
  • Docker Compose——通过 YAML 文件定义和运行多容器应用的工具
  • 网络命名空间(Network Namespace)——Linux 内核特性,为容器提供隔离的网络栈

知识关联

为什么 Docker 要用分层镜像?

分层镜像不是为了好看,而是解决三个核心痛点:存储效率传输效率构建效率。假设你有 100 个 Python 应用镜像,它们都基于 python:3.12-slim。如果不用分层,每个镜像都要独立存储完整的 Python 运行时(约 150MB),100 个就是 15GB。有了分层,100 个镜像共享同一份基础层,每个应用只额外存储自己的代码和依赖(通常几 MB),总存储可能只有几百 MB。

传输同理:你从 Docker Hub 拉取一个新版本镜像时,Docker 只下载变化的层,已有的层直接复用。构建时也一样——如果 Dockerfile 的前几行没变,Docker 直接用缓存,不用重新执行 apt installpip install。这种设计本质上是把"文件系统"变成了"内容寻址的块存储",每个层用 SHA256 哈希标识,相同哈希的层必定相同。

Docker vs Podman:容器运行时的两条路线

Docker 是目前最流行的容器平台,但 Podman 作为 Red Hat 主推的替代方案正在崛起。核心区别在于架构:Docker 有一个常驻的守护进程(dockerd),所有操作通过它中转;Podman 没有守护进程,每个容器是独立的 fork 出的子进程。这导致了几个关键差异:

  • 安全性:Docker 的守护进程以 root 运行,任何能访问 /var/run/docker.sock 的用户等于拥有 root 权限。Podman 支持 rootless 模式,普通用户也能运行容器,攻击面更小。
  • systemd 集成:Podman 原生支持生成 systemd 单元文件(podman generate systemd),与 Linux 服务管理无缝衔接。
  • 兼容性:Podman 的命令行接口与 Docker 几乎完全兼容(alias docker=podman 即可切换),镜像格式和 Dockerfile 也通用。
  • 生态:Docker 的生态更成熟(Docker Compose、Docker Desktop),Podman 在 RHEL/Fedora 生态中占主导。

选型建议:开发环境和个人项目用 Docker(生态完善、文档多);对安全性要求高的生产环境可以考虑 Podman,尤其是需要 rootless 容器的场景。

为什么推荐用 Dockerfile 而不是 docker commit?

docker commit 可以把运行中的容器保存为镜像,但官方强烈推荐用 Dockerfile,原因有三:

  • 可审计:Dockerfile 是纯文本文件,可以放进 Git 做版本控制,任何改动都有 diff 可查。docker commit 生成的镜像是黑箱——你不知道它里面到底改了什么。
  • 可复现:Dockerfile 每行指令对应一层,层的哈希是确定性的。同一份 Dockerfile 在任何机器上构建结果一致。docker commit 依赖容器的当前状态,不同时间构建的结果可能不同。
  • 可缓存:Docker 构建时自动缓存未变化的层。docker commit 每次都是全量创建新层,无法利用缓存加速。

简单说:Dockerfile 是"声明式基础设施",docker commit 是"快照式操作"。前者是 DevOps 的基础,后者只适合临时调试。

原理讲解

容器 vs 虚拟机:本质差异

许多人把容器类比为"轻量级虚拟机",但这掩盖了根本差异。虚拟机通过 Hypervisor 虚拟化硬件,每个 VM 运行独立的内核和操作系统。容器则共享宿主机内核,通过 Linux 内核的 namespace(隔离视图)和 cgroup(限制资源)实现进程级隔离。

特性容器虚拟机
内核共享宿主机内核每个 VM 运行独立内核
启动时间毫秒~秒级分钟级
镜像大小MB ~ 几百 MBGB 级(含完整 OS)
隔离粒度进程级(namespace)硬件级(Hypervisor)
资源开销仅进程开销CPU/内存/磁盘显著开销
类比理解

虚拟机像一栋栋独立别墅——每栋有自己的地基、水电、围墙;容器像公寓楼里的房间——共享大楼的管道和结构,但每间有独立的门锁和装修。

镜像分层与联合文件系统

Docker 镜像由多层只读层叠加而成。每一层对应 Dockerfile 中的一条指令,Docker 使用 Union Filesystem(如 overlay2)将这些层合并为统一的文件系统视图。当容器运行时,在最上层叠加一个可写层(容器层),所有写操作都发生在此层。

  容器层(可写)
  ─────────────
  CMD ["python", "app.py"]      ← 层 4(元数据)
  COPY . .                       ← 层 3
  RUN pip install -r req.txt    ← 层 2
  FROM python:3.12-slim          ← 层 1(基础镜像)

这种分层设计的优势:多个容器可以共享同一基础镜像层(节省磁盘和内存),构建时只有变化的层需要重新下载。

写时复制(Copy-on-Write)

当容器写入文件时,Docker 并不会修改底层的只读层,而是先把文件从只读层复制到容器可写层,再在可写层上修改——这就是写时复制(CoW)。它带来两个直接好处:多个容器共享同一基础镜像时,即使每个容器都改写了同一个文件,镜像本身也不会被污染;同一镜像并发启动几十个容器,磁盘占用仍约等于一个镜像的大小。代价是首次写入文件时有一次复制开销,因此频繁读写的目录(如 MySQL 数据目录)应通过数据卷绕过 CoW 层,直接读写宿主机磁盘。

# 验证写时复制:查看可写层占用
docker diff my-web
# 输出: C /etc/nginx/nginx.conf   (C=Changed, A=Added, D=Deleted)

# 容器层大小(可写层)
docker ps -s
# 输出: ... SIZE(仅可写层)  VIRTUAL SIZE(镜像+可写层合计)

镜像层复用与构建缓存

Docker 在构建时会为每条指令计算一个缓存键(指令内容 + 上下文文件指纹 + 父层 ID)。只要缓存键未变化,该层直接复用,不重新执行。这也是"先 COPY 依赖文件、后 COPY 代码"被反复强调的根本原因——requirements.txt 变更频率远低于业务代码,把它放在前面可以让 pip install 层命中缓存。

# 首次构建:全部层重新执行
docker build -t myapp:v1 .

# 修改 app.py 后二次构建:只有 COPY 层及其后层重建
docker build -t myapp:v2 .
# 输出:
#  => CACHED  [2/4] RUN pip install -r requirements.txt   ← 命中缓存
#  =>         [3/4] COPY app.py .                          ← 重新执行

# 查看每层创建时间,确认缓存复用
docker history myapp:v2 --no-trunc
缓存失效陷阱

COPY . . 会把整个构建上下文算入缓存键——任何文件变化都会让该层及后续所有层失效。配合 .dockerignore 排除日志、缓存、构建产物,能显著提高缓存命中率。

示例代码

1. Docker 基本操作

# 拉取镜像
docker pull nginx:alpine

# 查看本地镜像
docker images
# 输出: REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
#       nginx        alpine    abc123def456   2 weeks ago   42MB

# 运行容器(前台)
docker run nginx:alpine

# 运行容器(后台 + 端口映射 + 命名)
docker run -d --name my-web -p 8080:80 nginx:alpine

# 查看运行中的容器
docker ps
# 输出: CONTAINER ID   IMAGE          COMMAND   PORTS                  NAMES
#       xyz789        nginx:alpine   "..."     0.0.0.0:8080->80/tcp   my-web

# 查看所有容器(含已停止)
docker ps -a

# 进入容器内部
docker exec -it my-web sh

# 查看容器日志
docker logs my-web
docker logs -f my-web  # 实时跟踪

# 停止、启动、删除
docker stop my-web
docker start my-web
docker rm my-web
docker rm -f my-web  # 强制删除运行中的容器

2. 编写 Dockerfile

# Dockerfile — 构建一个简单的 Python Web 应用
FROM python:3.12-slim

WORKDIR /app

# 先复制依赖文件(利用缓存层)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 再复制应用代码
COPY app.py .

EXPOSE 8000

CMD ["python", "app.py"]

# 构建镜像
docker build -t my-web-app:v1 .

# 运行
docker run -d -p 8000:8000 my-web-app:v1

# 检查构建历史中的分层
docker history my-web-app:v1

3. 数据持久化

# 方式一:Volume(推荐,由 Docker 管理)
docker volume create nginx-html
docker run -d --name web -v nginx-html:/usr/share/nginx/html nginx

# 查看 Volume 信息
docker volume inspect nginx-html
# 输出: { "Mountpoint": "/var/lib/docker/volumes/nginx-html/_data" }

# 方式二:Bind Mount(映射宿主机目录)
docker run -d --name web-dev \
  -v $(pwd)/html:/usr/share/nginx/html \
  -p 8080:80 nginx

# 方式三:tmpfs(仅存内存,适合临时数据)
docker run -d --name cache --tmpfs /cache nginx

4. Docker Compose 多容器编排

Docker Compose V2 已合并到 Docker CLI,命令从 docker-compose(带连字符)改为 docker compose(空格)。新项目不再需要 version 字段。

# docker-compose.yml
services:
  web:
    build: .
    ports:
      - "8000:8000"
    depends_on:
      - db
    environment:
      - DB_HOST=db
      - DB_NAME=myapp

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp
      POSTGRES_PASSWORD: secret

volumes:
  pgdata:

# 启动所有服务
docker compose up -d
# 输出: [+] Running 3/3
#       ✔ Container myweb-web-1  Started
#       ✔ Container myweb-db-1   Started

# 查看服务日志
docker compose logs -f web

# 停止并清理
docker compose down
docker compose down -v  # 同时删除 volume

5. 镜像管理技巧

# 给镜像打标签
docker tag my-web-app:v1 myrepo/my-web-app:latest

# 推送到远程仓库
docker login
docker push myrepo/my-web-app:latest

# 查看容器资源使用
docker stats

# 清理未使用的资源
docker system prune        # 清理停止的容器、未使用的网络、 dangling 镜像
docker system prune -a     # 额外清理未使用的镜像

# 查看容器的详细信息
docker inspect my-web | jq '.[0].NetworkSettings.IPAddress'

6. 网络模式详解

Docker 默认创建 bridge 网络,容器通过虚拟网桥(docker0)互相通信并与外部通信。--network 参数可以切换网络模式:

网络模式原理适用场景示例
bridge(默认)容器连接到 docker0 虚拟网桥,通过 NAT 访问外网单机多容器互通,端口映射对外docker run -p 8080:80 nginx
host容器与宿主机共享网络栈,直接使用宿主机 IP 和端口追求最低网络延迟;端口多而杂的应用docker run --network host nginx
none容器无网络接口,完全隔离安全敏感任务,仅需本地回环docker run --network none busybox
overlay跨宿主机虚拟网络(Swarm/K8s 使用)多机集群容器互通docker network create -d overlay mynet
自定义 bridge用户创建网桥,支持 DNS 名字互访多容器应用按逻辑分组docker network create mynet
# 创建自定义网络并测试容器间 DNS 解析
docker network create --subnet=172.20.0.0/16 mynet

docker run -d --name web1 --network mynet nginx
docker run -d --name web2 --network mynet nginx

# 在 web1 内用名字 ping web2(自定义网络内置 DNS)
docker exec web1 ping -c 2 web2
# 输出: PING web2 (172.20.0.3) 56(84) bytes of data.

# 断开/连接网络
docker network disconnect mynet web2
docker network connect mynet web2

# 查看网络详情
docker network inspect mynet | grep -A5 '"Containers"'

7. 数据卷深入:Volume vs Bind Mount

维度VolumeBind Mount
存放位置/var/lib/docker/volumes/<name>/_data(Docker 管理)宿主机任意路径,由你指定
管理方式docker volume 命令族完整管理直接操作文件系统
备份迁移容易(docker run --volumes-fromtar 导出)路径即备份,依赖宿主机
性能原生文件系统,性能最好直接读写,同样接近原生
适用数据库数据、应用状态、跨容器共享开发热更新代码、传递配置文件
# 匿名卷:容器删除后残留,需手动清理
docker run -d --name temp -v /data busybox sleep 3600
docker rm temp        # 匿名卷不会被自动删除
docker volume ls      # 出现一堆随机命名的匿名卷

# 清理所有未被任何容器使用的匿名卷
docker volume prune
# 输出: WARNING! This will remove anonymous local volumes not used by at least one container.

# 从一个容器复制数据到另一个容器(--volumes-from)
docker run -d --name web-backup --volumes-from my-web nginx

# 备份卷内容为 tar 包
docker run --rm -v nginx-html:/data -v $(pwd):/backup alpine \
  tar czf /backup/nginx-html.tar.gz -C /data .

8. 资源限制

# 限制内存:最多 512MB,超出即 OOM 杀进程
docker run -d --name web --memory 512m --memory-swap 512m nginx

# 限制 CPU:最多使用 1.5 个核心
docker run -d --name web --cpus 1.5 nginx

# 限制 CPU 份额(相对权重,默认 1024)
docker run -d --name web --cpu-shares 512 nginx

# 查看容器实时资源占用
docker stats
# 输出: CONTAINER ID   NAME   CPU %   MEM USAGE / LIMIT   MEM %   NET I/O
#       4a2b3c4d5e6f   web    0.05%   8.219MiB / 512MiB   1.60%   1.2kB / 648B

# 容器内验证限制生效
docker exec web cat /sys/fs/cgroup/memory.max   # cgroup v2
docker exec web nproc                           # 显示可用 CPU 数
限制放行的原则

只给限制、不设上限的容器会在内存压力下争抢宿主机的页缓存,导致其他服务被 OOM Killer 误杀。生产环境建议:CPU 按 --cpus 精确分配,内存必须同时设置 --memory--memory-swap

9. 多阶段构建与 .dockerignore

# .dockerignore —— 与 .gitignore 同目录,排除无关文件
.git
node_modules
__pycache__
*.log
.env
dist
*.md
# 多阶段构建:Go 程序,最终镜像仅几 MB
FROM golang:1.22 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app main.go

FROM scratch
COPY --from=builder /app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

# 构建并对比镜像大小
docker build -t goapp:multi .
docker images | grep goapp
# 输出: goapp   multi   1.2MB   (而 golang:1.22 基础镜像约 800MB)

# 构建时传参(--build-arg),配合 ARG 指令实现环境差异化
docker build --build-arg VERSION=2.0 -t goapp:v2.0 .

常见错误

错误表现根因正确做法
port is already allocated宿主机端口已被其他容器或进程占用sudo ss -tlnp 查看端口占用,换用其他端口映射(-p 8081:80
permission denied 挂载目录容器的用户 ID 对挂载的宿主机目录无权限挂载前 chown 1000:1000 目录权限,或用 user: "1000:1000" 在 compose 中指定用户
容器启动后立刻退出(Exited 0)容器内的进程在后台运行但没有保持前台进程容器必须运行前台进程;用 docker logs 查看日志,确保 CMD/ENTRYPOINT 是前台命令
docker build 每次都重新安装依赖COPY . . 放在 RUN pip install 之前,导致代码变化时缓存失效COPY requirements.txt . 再 pip install,最后 COPY . .(利用 Docker 层缓存)
Get https://registry-1.docker.io/v2/: net/http: TLS handshake timeout网络问题无法连接到 Docker Hub编辑 /etc/docker/daemon.jsonregistry-mirrors 配置镜像加速器后 systemctl restart docker--registry-mirror 是 dockerd 启动参数,不是 docker run/build 的参数)
no space left on device 构建/运行失败镜像、容器、卷占满磁盘,或 overlay2 层数超限docker system df 查看占用分布,docker system prune -a 清理,扩容 /var/lib/docker 所在分区
容器之间无法用容器名互访容器在默认 bridge 网络,而默认 bridge 不支持 DNS 解析容器名创建自定义网络:docker network create mynet,启动时加 --network mynet;compose 项目默认自带该能力
Cannot connect to the Docker daemon at unix:///var/run/docker.sock当前用户不在 docker 组,或 docker 服务未启动sudo systemctl start dockersudo usermod -aG docker $USER 后重新登录(生产环境注意该组等同 root 权限)
docker run --memory 设置后容器频繁被杀只设置了 --memory,swap 上限仍是内存的 2 倍,内存压力下超限被杀--memory-swap--memory 设为相同值,禁止容器使用 swap

最佳实践

实践原理示例
选择轻量基础镜像减小攻击面和镜像大小,加快部署优先用 alpine(5MB)而非 ubuntu(约 80MB)
利用 Docker 层缓存加速构建不变层会被缓存,只重构建变化的层先 COPY 依赖文件,RUN install,最后 COPY 应用代码
多阶段构建(Multi-stage)分离构建环境和运行环境,大幅缩减镜像第一阶段用 golang:1.22 编译,第二阶段 COPY 编译产物到 alpine
不要以 root 运行容器最小权限原则,降低容器逃逸风险Dockerfile 中 USER nobody 或用 compose 的 user: 指令
一个容器只跑一个进程简化管理,便于扩缩容和健康检查Nginx 和 PHP-FPM 分两个容器,用 Compose 编排
使用 .dockerignore 排除无关文件减少镜像上下文大小,加快构建排除 node_modules.git.env__pycache__
数据库等有状态应用挂载命名卷数据与容器解耦,容器可随时重建-v mysql-data:/var/lib/mysql,而非挂载到可写层
容器运行参数固定化重建容器时行为可复现资源限制(--cpus/--memory)、重启策略(--restart unless-stopped)写进 docker run 或 compose
用自定义网络替代默认 bridge获得内置 DNS 与按组隔离docker network create app-net--network app-net 启动全部相关容器

练习题

  1. (概念)容器和虚拟机的根本区别是什么?为什么容器启动比虚拟机快得多?
  2. (概念)Docker 镜像的分层结构有什么好处?当你修改了 Dockerfile 中的一行代码(COPY app.py .),哪些层会被重新构建?
  3. (实操)编写一个 Dockerfile,基于 nginx:alpine,将宿主机 ./www 目录下的静态网站文件复制到 /usr/share/nginx/html,构建镜像并在本地 8080 端口运行验证。
  4. (实操)编写 docker-compose.yml,定义两个服务:web(基于 nginx)和 redis(基于 redis:alpine),让 web 服务能通过 redis 这个主机名连接 Redis。使用 docker compose up -d 启动并验证两个容器都在运行。
  5. (🔍 挑战)练习多阶段构建:编写一个 Go 程序 main.go(打印 "Hello from container"),用多阶段 Dockerfile 构建——第一阶段用 golang:1.22 编译,第二阶段将编译产物复制到 scratch 空镜像。比较最终镜像大小和使用完整 golang 基础镜像的差异。
点击查看答案
  1. (概念)容器共享宿主机内核,通过 namespace 和 cgroup 实现进程隔离;虚拟机包含完整 Guest OS,通过 Hypervisor 实现硬件级虚拟化。容器启动更快(毫秒级 vs 分钟级),因为没有引导完整操作系统的开销。
  2. (概念)镜像分层让多个镜像共享基础层,节省存储和网络传输;修改 COPY app.py . 后,该层及其之后的所有层都会被重新构建,该层之前的缓存层可以复用。利用这一特性应将不常变化的依赖安装放在前面以最大化缓存命中。
  3. (实操)Dockerfile:FROM nginx:alpineCOPY ./www /usr/share/nginx/htmldocker build -t my-nginx . && docker run -d -p 8080:80 my-nginx。验证:curl localhost:8080。常见错误:COPY 源路径是构建上下文中的相对路径,./www 目录必须存在。
  4. (实操)docker-compose.yml 中:services: web: image: nginx ports: - "8080:80" depends_on: - redis; redis: image: redis:alpine。web 服务中可直接用 redis 主机名连接 Redis(Compose 内置 DNS 解析)。验证:docker compose ps 显示两个容器状态均为 Up。
  5. (🔍 挑战)多阶段 Dockerfile:第一阶段 FROM golang:1.22 AS builderCOPY main.go .go build -o /app main.go;第二阶段 FROM scratchCOPY --from=builder /app /appCMD ["/app"]scratch 镜像仅几 KB,而 golang:1.22 约 800MB。注意 Go 程序需静态编译:CGO_ENABLED=0 GOOS=linux go build

学习检查点

学完本章后,请检验自己是否掌握以下内容:

检查项自测问题验证方法
概念理解能用自己的话解释 Docker 容器与虚拟机的本质区别尝试向他人讲解
命令操作能不查文档完成 Docker 镜像拉取、容器运行、Dockerfile 编写在终端实际执行
原理掌握能说出 Docker 镜像分层与联合文件系统的工作原理画出流程图
故障排查能独立排查 Docker 容器启动失败、网络不通等问题模拟故障并修复
最佳实践能说明为什么推荐用 Dockerfile 而不是 docker commit对比不同方案

本章总结

Docker 通过 Linux 内核的 namespace 和 cgroup 实现了轻量级进程隔离,从根本上改变了应用的打包、分发和运行方式。理解镜像分层、容器生命周期和数据持久化是入门的关键。Docker Compose 填补了单容器命令的不足,让多服务应用的编排变得声明式可重复。"一次构建,到处运行"不仅是一句口号——它是 Docker 解决"在我机器上能跑"这个经典问题的答案。

速查表

命令/概念用途
docker pull <image>拉取镜像
docker run -d -p <host>:<cont> --name <n> <image>运行容器(后台 + 端口映射)
docker exec -it <container> sh进入容器内部
docker build -t <tag> .构建镜像
docker compose up -d启动 Compose 服务
docker volume create / -v创建和挂载 Volume
docker system prune -a清理所有未使用资源
docker logs -f <container>实时跟踪容器日志
docker stats实时查看容器资源使用
.dockerignore排除构建上下文中的文件

学习路径建议

延伸阅读

常见问题

Docker 容器退出后数据会丢失吗?
容器被删除(docker rm)后,写入容器可写层的数据确实会丢失。持久化数据必须使用卷(volume)或绑定挂载(bind mount)。卷保存在 /var/lib/docker/volumes/,绑定挂载直接将宿主机目录映射到容器。生产环境必须用卷或挂载来保存数据库和其他重要数据。
Dockerfile 中 RUN、CMD、ENTRYPOINT 有什么区别?
RUN 在构建阶段执行命令,结果会提交为新镜像层。CMD 提供容器启动时的默认命令,可以被 docker run 参数覆盖。ENTRYPOINT 类似 CMD 但不可被覆盖(除非 --entrypoint 参数)。组合使用:ENTRYPOINT 写固定部分,CMD 写默认参数。
docker-compose 和 docker stack 怎么选?
单机多容器用 docker-compose(更成熟、功能完整)。docker stack 是 Docker Swarm 模式的一部分,支持多节点编排,但功能较 compose 少。实际项目中大部分场景用 docker-compose.yml,K8s 环境则用 Helm chart。
↑ 回到顶部