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 内核特性,为容器提供隔离的网络栈
知识关联
- 前置知识:1.4:基本文件操作命令 文件操作、1.8:软件包管理 软件包管理、2.4:进程管理 进程管理
- 后续影响:Docker 是 6.1:容器底层原理 容器底层原理、5.2:Docker 生产实践 Docker 生产实践、6.2:Kubernetes 入门 Kubernetes 的基础
- 配套技术:Docker Compose、Docker Hub、Harbor(私有镜像仓库)
为什么 Docker 要用分层镜像?
分层镜像不是为了好看,而是解决三个核心痛点:存储效率、传输效率和构建效率。假设你有 100 个 Python 应用镜像,它们都基于 python:3.12-slim。如果不用分层,每个镜像都要独立存储完整的 Python 运行时(约 150MB),100 个就是 15GB。有了分层,100 个镜像共享同一份基础层,每个应用只额外存储自己的代码和依赖(通常几 MB),总存储可能只有几百 MB。
传输同理:你从 Docker Hub 拉取一个新版本镜像时,Docker 只下载变化的层,已有的层直接复用。构建时也一样——如果 Dockerfile 的前几行没变,Docker 直接用缓存,不用重新执行 apt install 或 pip 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 ~ 几百 MB | GB 级(含完整 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
| 维度 | Volume | Bind Mount |
|---|---|---|
| 存放位置 | /var/lib/docker/volumes/<name>/_data(Docker 管理) | 宿主机任意路径,由你指定 |
| 管理方式 | docker volume 命令族完整管理 | 直接操作文件系统 |
| 备份迁移 | 容易(docker run --volumes-from、tar 导出) | 路径即备份,依赖宿主机 |
| 性能 | 原生文件系统,性能最好 | 直接读写,同样接近原生 |
| 适用 | 数据库数据、应用状态、跨容器共享 | 开发热更新代码、传递配置文件 |
# 匿名卷:容器删除后残留,需手动清理
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.json 的 registry-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 docker;sudo 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 启动全部相关容器 |
练习题
- (概念)容器和虚拟机的根本区别是什么?为什么容器启动比虚拟机快得多?
- (概念)Docker 镜像的分层结构有什么好处?当你修改了 Dockerfile 中的一行代码(
COPY app.py .),哪些层会被重新构建? - (实操)编写一个 Dockerfile,基于
nginx:alpine,将宿主机./www目录下的静态网站文件复制到/usr/share/nginx/html,构建镜像并在本地 8080 端口运行验证。 - (实操)编写 docker-compose.yml,定义两个服务:
web(基于 nginx)和redis(基于 redis:alpine),让 web 服务能通过redis这个主机名连接 Redis。使用docker compose up -d启动并验证两个容器都在运行。 - (🔍 挑战)练习多阶段构建:编写一个 Go 程序
main.go(打印 "Hello from container"),用多阶段 Dockerfile 构建——第一阶段用golang:1.22编译,第二阶段将编译产物复制到scratch空镜像。比较最终镜像大小和使用完整 golang 基础镜像的差异。
点击查看答案
- (概念)容器共享宿主机内核,通过 namespace 和 cgroup 实现进程隔离;虚拟机包含完整 Guest OS,通过 Hypervisor 实现硬件级虚拟化。容器启动更快(毫秒级 vs 分钟级),因为没有引导完整操作系统的开销。
- (概念)镜像分层让多个镜像共享基础层,节省存储和网络传输;修改
COPY app.py .后,该层及其之后的所有层都会被重新构建,该层之前的缓存层可以复用。利用这一特性应将不常变化的依赖安装放在前面以最大化缓存命中。 - (实操)Dockerfile:
FROM nginx:alpine→COPY ./www /usr/share/nginx/html→docker build -t my-nginx . && docker run -d -p 8080:80 my-nginx。验证:curl localhost:8080。常见错误:COPY源路径是构建上下文中的相对路径,./www目录必须存在。 - (实操)
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。 - (🔍 挑战)多阶段 Dockerfile:第一阶段
FROM golang:1.22 AS builder→COPY main.go .→go build -o /app main.go;第二阶段FROM scratch→COPY --from=builder /app /app→CMD ["/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 | 排除构建上下文中的文件 |
学习路径建议
- 学完本章后建议阅读 6.1:容器底层原理 容器底层原理,深入理解 namespace/cgroup/UnionFS
- 生产环境实践可看 5.2:Docker 生产实践 Docker 生产实践
- Compose 生产级部署可看 container_ops01 Docker Compose 生产部署实战
- 容器编排的下一步是 6.2:Kubernetes 入门 Kubernetes 入门