HomeArchiveBlog


Original contents are licensed under CC BY-NC 4.0. All rights reserved © 2026 Kai.
Back to Archives
Basic Skills: Docker

A beginner-friendly introduction to Docker, including containers, images, namespaces, cgroups, common commands, permissions, networking, bind mounts, ports, Dockerfiles, registries, layered builds, and Docker Compose.

Wed Apr 29 2026
Tue Apr 28 2026
DockerContainerizationVirtualization
On this page
  • Basic Skills: Docker
    • Introduction
    • 你应该学会什么
    • Namespace and Cgroups
      • Docker on MacOS/Windows
    • Docker 基础命令
      • 开发用法和分发用法
      • docker run
    • Docker 的权限问题
    • Docker 的网络模型
    • 挂载宿主机文件系统
    • 端口映射
    • 运行交互式容器
    • 以特权模式运行容器
    • Dockerfile 和镜像构建
      • 进阶: 分层构建
    • Docker 镜像仓库
    • 进阶: Docker Compose

Basic Skills: Docker

Introduction

容器 (Container) 是一种轻量级的虚拟化技术, 允许你在同一台物理机上运行多个隔离的环境. 它和 VM 的区别在于, VM 需要模拟整个操作系统, 包括内核, 驱动, 文件系统等; 而容器直接利用宿主机的内核, 只隔离用户空间. 因此, 容器启动更快, 占用资源更少.

我们经常会遇到某个奇奇怪怪的项目发来的各种依赖要求千奇百怪的软件, 很容易弄得本地环境一团糟. 这时候, Docker 就能派上用场了. 你可以在 Docker 容器里安装和配置好项目需要的环境, 包括操作系统版本, 库依赖, 环境变量等. 然后把这个容器打包成一个镜像 (Image), 发给别人或者部署到服务器上. 别人只需要运行这个镜像, 就能得到和你一样的环境, 不用担心依赖冲突或者环境不一致的问题.

你应该学会什么

这篇文档的目标不是让你成为容器平台管理员, 而是让你能把 Docker 作为开发和实验环境管理工具稳定地用起来. 对大多数 CS/ECE 学生来说, Docker 最重要的价值是隔离依赖, 复现环境, 跑别人的项目, 以及把自己的项目交给别人运行.

目标
  • 理解容器和虚拟机的区别, 知道 Docker 不是一个完整虚拟机, 它依赖 Linux 内核提供隔离能力.
  • 理解镜像 (Image), 容器 (Container), Dockerfile, Registry 之间的关系.
  • 能使用 docker run, docker ps, docker exec, docker logs, docker stop, docker rm, docker images 等基础命令.
  • 能通过 bind mount 或 volume 把宿主机目录挂载到容器中, 并理解文件权限为什么经常出问题.
  • 能通过端口映射访问容器里的服务.
  • 能写简单 Dockerfile, 构建镜像, 运行镜像.
  • 能读懂项目里的 docker compose.yml, 并用 Docker Compose 启动多容器开发环境.
  • 对于 ECE 学生, 还应该能用 Docker 固定编译器, 仿真器, Python 依赖, 交叉编译工具链, 并在不污染本机环境的情况下复现实验结果.

Docker 的学习边界也要明确: Docker 不是万能沙箱. 它默认隔离程度比普通进程强, 但弱于真正的虚拟机. 如果你运行不可信代码, 尤其是加了 --privileged, 挂载了宿主机敏感目录, 或者把用户加入了 docker 组, 就要把它视为有较高风险的操作.

Namespace and Cgroups

Docker 容器依赖 Linux 内核里的两个核心机制: namespaces 和 cgroups.

Namespace 负责"看见什么". 它让不同进程看到不同的系统视图. 常见 namespace 包括:

  • PID namespace: 容器里看到自己的进程树, 容器内的主进程通常是 PID 1.
  • Mount namespace: 容器看到自己的文件系统挂载表.
  • Network namespace: 容器有自己的网卡, IP, 路由表, 端口空间.
  • UTS namespace: 容器可以有自己的 hostname.
  • IPC namespace: 隔离进程间通信资源.
  • User namespace: 把容器内用户映射到宿主机上的不同用户, 用于降低权限风险.

例如, 你在容器里运行:

ps aux
hostname
ip addr

看到的进程, 主机名和网卡都可能和宿主机不同. 这不是因为 Docker 启动了另一个完整内核, 而是同一个 Linux 内核给这个进程提供了不同的视图.

Cgroups 负责"能用多少". 它限制和统计进程组的资源使用, 例如:

  • CPU 使用量.
  • 内存上限.
  • Block I/O.
  • Pids 数量.
  • 设备访问权限.

例如限制容器最多使用 512 MB 内存:

docker run --rm -m 512m ubuntu:24.04 bash

限制容器最多使用 1 个 CPU:

docker run --rm --cpus=1 ubuntu:24.04 bash

所以, Docker 的本质不是"模拟一台机器", 而是在 Linux 上启动一个普通进程, 然后用 namespace 改变它看到的世界, 用 cgroups 限制它能使用的资源, 再给它准备一个隔离的文件系统.

这也解释了为什么 Linux 上 Docker 比虚拟机轻: 容器不需要启动另一个内核, 不需要模拟硬件, 不需要跑完整操作系统. 容器里的 Ubuntu, Debian, Alpine 等只是用户空间里的文件和程序, 它们最终仍然调用宿主机 Linux 内核.

Docker on MacOS/Windows

严格来说, Docker 容器需要 Linux 内核提供 namespaces 和 cgroups. macOS 的内核是 XNU, Windows 的内核也不是 Linux 内核, 因此它们不能原生运行 Linux Docker 容器.

这就是为什么说 macOS/Windows 没有真正 native 的 Linux Docker. 它们通常通过一个隐藏的 Linux 虚拟机来运行 Docker:

  • macOS: Docker Desktop 会启动一个轻量 Linux VM, Docker Engine 运行在这个 VM 里. 你在 macOS 终端里输入 docker 命令, 实际上是通过 Docker CLI 和 VM 里的 Docker daemon 通信.
  • Windows: Docker Desktop 通常使用 WSL2 后端. WSL2 本身就是一个运行 Linux 内核的轻量虚拟化环境, Docker Engine 运行在 WSL2 的 Linux 环境里.
  • 早期 Windows Docker Desktop 也可以使用 Hyper-V 后端, 原理同样是通过 Linux VM 运行 Linux 容器.

这会带来几个实际影响:

  • 文件系统性能: 在 macOS/Windows 上把宿主机目录挂载进容器, 需要跨越宿主系统和 Linux VM 的文件共享层, 性能通常比 Linux 原生 bind mount 慢.
  • 路径差异: macOS/Windows 的路径会被 Docker Desktop 转换后挂载到 Linux VM 中.
  • 权限差异: macOS/Windows 的文件权限模型和 Linux 不同, Docker Desktop 需要做映射, 有时会出现和 Linux 服务器上不同的权限表现.
  • 网络差异: 容器实际运行在 VM 内, 所以 localhost, bridge network, host network 的行为可能和 Linux 原生 Docker 不完全一致.

如果你的目标是部署到 Linux 服务器, 最终最好在 Linux 环境里测试一遍 Docker 行为. macOS/Windows 上的 Docker Desktop 非常适合开发, 但它不是 Linux 原生 Docker 的完全等价替身.

Docker 基础命令

Docker 的核心对象有三个:

  • Image: 镜像, 类似只读模板. 例如 ubuntu:24.04, python:3.12.
  • Container: 容器, 镜像运行起来后的实例. 一个镜像可以启动多个容器.
  • Registry: 镜像仓库, 用来保存和分发镜像. Docker Hub 是最常见的公共仓库.

开发用法和分发用法

Docker 有两种很常见的使用方式.

第一种是开发用法: 把 Docker 当成可丢弃的开发环境. 你把源码目录挂载进容器, 在容器里安装编译器, Python 包, EDA 工具依赖, 然后编译或运行项目. 这种方式强调迭代方便, 常见参数是 -it, --rm, -v, -w, -u.

docker run --rm -it \
    -u "$(id -u):$(id -g)" \
    -v "$PWD:/work" \
    -w /work \
    my-dev:latest \
    bash

第二种是分发用法: 把应用和运行依赖打进镜像, 推送到镜像仓库, 别人在服务器上拉取并运行. 这种方式强调镜像小, 启动命令明确, 配置通过环境变量或挂载传入, 常见参数是 -d, --name, -p, -e, --restart.

docker run -d \
    --name my-app \
    -p 127.0.0.1:8080:8080 \
    -e APP_ENV=production \
    ghcr.io/user/my-app:v1.0.0

开发镜像可以大一些, 里面带调试工具和编译工具链; 分发镜像应该尽量小, 只包含运行时需要的文件. 这就是后面分层构建和多阶段构建要解决的问题.

拉取镜像:

docker pull ubuntu:24.04
docker pull python:3.12-slim

查看本地镜像:

docker images

运行一个容器:

docker run ubuntu:24.04 echo hello

这条命令会基于 ubuntu:24.04 镜像启动一个容器, 在容器里执行 echo hello, 命令结束后容器退出.

查看正在运行的容器:

docker ps

查看所有容器, 包括已经退出的:

docker ps -a

给容器起名字:

docker run --name my-ubuntu ubuntu:24.04 echo hello

删除已经退出的容器:

docker rm my-ubuntu

使用 --rm 表示容器退出后自动删除:

docker run --rm ubuntu:24.04 echo hello

停止正在运行的容器:

docker stop <container>

强制删除容器:

docker rm -f <container>

查看容器日志:

docker logs <container>
docker logs -f <container>

-f 类似 tail -f, 会持续跟随新日志.

进入一个正在运行的容器:

docker exec -it <container> bash

如果镜像里没有 bash, 可以尝试:

docker exec -it <container> sh

查看 Docker 占用空间:

docker system df

清理未使用的容器, 网络, dangling images, build cache:

docker system prune

这个命令会删除不再使用的对象. 如果你不确定镜像是否还需要, 不要随手加 -a, 因为 docker system prune -a 会删除所有当前没有被容器使用的镜像.

docker run

docker run 是最常用的 Docker 命令. 它做了三件事: 基于镜像创建容器, 启动容器, 在容器里执行指定命令.

基本形式是:

docker run [OPTIONS] IMAGE [COMMAND] [ARG...]

例如:

docker run --rm ubuntu:24.04 echo hello

这里 ubuntu:24.04 是镜像, echo hello 是容器启动后执行的命令. 如果不写命令, Docker 会使用镜像里定义的 CMD 或 ENTRYPOINT.

最常见参数:

  • --rm: 容器退出后自动删除, 适合临时任务.
  • -it: 交互式终端, 常用于进入 shell.
  • -d: 后台运行, 常用于服务.
  • --name: 给容器命名.
  • -v 或 --mount: 挂载目录或 volume.
  • -w: 设置工作目录.
  • -p: 端口映射.
  • -e: 设置环境变量.
  • --env-file: 从文件读取环境变量.
  • -u: 指定容器内进程使用的 UID/GID.
  • --network: 指定网络模式或网络名.
  • --restart: 设置重启策略.
  • --entrypoint: 覆盖镜像入口程序.

启动一个临时 Ubuntu shell:

docker run --rm -it ubuntu:24.04 bash

挂载当前目录并设置工作目录:

docker run --rm -it \
    -v "$PWD:/work" \
    -w /work \
    ubuntu:24.04 \
    bash

用当前用户身份运行, 避免输出文件变成 root 拥有:

docker run --rm -it \
    -u "$(id -u):$(id -g)" \
    -v "$PWD:/work" \
    -w /work \
    ubuntu:24.04 \
    bash

运行后台服务并映射端口:

docker run -d \
    --name web \
    -p 127.0.0.1:8080:80 \
    nginx

传入环境变量:

docker run --rm \
    -e APP_ENV=dev \
    -e LOG_LEVEL=debug \
    my-app:latest

从文件传入环境变量:

docker run --rm --env-file .env my-app:latest

.env 文件通常长这样:

APP_ENV=dev
LOG_LEVEL=debug

后台服务如果希望 Docker daemon 重启后自动恢复, 可以设置重启策略:

docker run -d \
    --name web \
    --restart unless-stopped \
    -p 8080:80 \
    nginx

常见重启策略:

  • no: 不自动重启, 默认值.
  • on-failure: 只有异常退出时重启.
  • unless-stopped: 除非手动停止, 否则 daemon 重启后也会恢复.
  • always: 总是尝试重启.

覆盖入口程序适合调试镜像:

docker run --rm -it --entrypoint bash my-app:latest

如果镜像里没有 bash, 可以换成 sh.

复杂命令建议写成多行, 每一类参数放在一起. 这样比一长行更容易检查挂载, 端口和权限:

docker run --rm -it \
    --name dev \
    -u "$(id -u):$(id -g)" \
    -v "$PWD:/work" \
    -w /work \
    -e CC=clang \
    -p 127.0.0.1:8000:8000 \
    my-dev:latest \
    bash

如果命令变得更长, 通常说明应该考虑写 docker compose.yml 或 Makefile, 避免每次手动输入出错.

Docker 的权限问题

Docker 的权限问题有两层: 谁能控制 Docker daemon, 以及容器里的用户如何映射到宿主机文件.

首先, Docker daemon 通常以 root 权限运行. 能访问 Docker socket 的用户, 基本上就能获得宿主机上的 root 级别能力. 因此把用户加入 docker 组并不是一个普通授权:

sudo usermod -aG docker $USER

加入后, 这个用户可以运行:

docker run --rm -it -v /:/host ubuntu:24.04 bash

这会把宿主机根目录挂载进容器. 如果容器里又以 root 身份运行, 就可能修改宿主机文件. 所以, docker 组应当被视为近似 root 权限.

第二层是容器内用户和文件权限. 默认情况下, 很多镜像里的进程以 root 用户运行. 如果你把当前目录挂载进容器, 容器里创建的文件可能会变成 root 拥有:

docker run --rm -v "$PWD:/work" -w /work ubuntu:24.04 touch output.txt
ls -l output.txt

在 Linux 原生 Docker 上, 文件权限按数字 UID/GID 判断. 容器里的 root 是 UID 0, 宿主机也把这个文件看成 UID 0 创建的文件.

常见解决方式是用当前用户的 UID/GID 运行容器:

docker run --rm \
    -u "$(id -u):$(id -g)" \
    -v "$PWD:/work" \
    -w /work \
    ubuntu:24.04 \
    touch output.txt

这样容器里创建的文件在宿主机上会属于当前用户.

有些镜像内部需要用户存在于 /etc/passwd, 只传 UID/GID 可能导致用户名显示异常, 但文件权限通常仍然是正确的. 更完整的做法是在 Dockerfile 里创建非 root 用户, 后面会介绍.

Docker 也支持 rootless mode 和 user namespace remapping, 用来降低 daemon 或容器 root 带来的风险. 但这些配置会改变网络, 存储和权限行为, 对初学者来说可以先知道它们存在, 不必一开始就使用.

Docker 的网络模型

Docker 默认使用 bridge 网络. 可以把它理解成 Docker 在宿主机上创建了一个虚拟交换机, 每个容器接入这个交换机, 获得一个内部 IP. 容器可以通过 NAT 访问外网, 但外部不能直接访问容器端口, 除非你做端口映射.

查看网络:

docker network ls
docker network inspect bridge

常见网络模式:

  • bridge: 默认模式, 容器接入 Docker 创建的虚拟网桥.
  • host: 容器直接使用宿主机网络命名空间, Linux 上可用, 隔离更弱.
  • none: 容器没有网络.
  • 自定义 bridge 网络: 适合多个容器互相通过名字访问.

创建自定义网络:

docker network create mynet

启动两个容器加入同一个网络:

docker run -d --name redis --network mynet redis:7
docker run --rm -it --network mynet redis:7 redis-cli -h redis ping

第二个容器可以用 redis 这个容器名访问第一个容器. 这是 Docker 内部 DNS 提供的能力.

使用 host 网络:

docker run --rm --network host nginx

host 网络下容器和宿主机共享网络栈, 不需要 -p 做端口映射. 但它也意味着网络隔离变弱, 端口冲突更直接. 在 macOS/Windows 上, 因为 Docker 实际运行在 Linux VM 中, host 网络的行为和 Linux 原生 Docker 不完全一样.

挂载宿主机文件系统

容器自己的文件系统是临时的. 如果你在容器里写文件, 容器删除后这些改动通常也会消失. 想让数据持久化, 或者让容器访问宿主机工程目录, 就需要挂载.

从结构上看, 容器文件系统通常由两部分组成:

  • 镜像层: 来自 image 的只读 layer, 例如基础系统, 已安装软件, 复制进去的源码.
  • 容器可写层: 容器启动后新增和修改的文件写在这里.

当你删除容器时, 容器可写层也会被删除, 但镜像层仍然保留. 这就是为什么同一个镜像可以反复启动新容器, 但一个容器里临时安装的软件不会自动影响下一个容器.

挂载会把宿主机目录或 volume 接到容器文件系统的某个路径上. 如果挂载目标路径原本在镜像里已经有文件, 挂载后这个路径会被挂载内容覆盖. 例如:

docker run --rm -v "$PWD:/usr/share/nginx/html" nginx

此时 nginx 镜像里原本的 /usr/share/nginx/html 内容会被你当前目录覆盖. 这不是删除了镜像里的文件, 而是 mount namespace 里这个路径被新的挂载点遮住了.

常见挂载方式有三种:

  • Bind mount: 把宿主机某个具体路径挂载到容器中.
  • Volume: Docker 管理的数据卷, 位置由 Docker 管理.
  • tmpfs: 只存在内存中的临时文件系统.

Bind mount 最常用于开发:

docker run --rm -it \
    -v "$PWD:/work" \
    -w /work \
    ubuntu:24.04 \
    bash

这里:

  • -v "$PWD:/work": 把当前目录挂载到容器的 /work.
  • -w /work: 设置容器启动后的工作目录.
  • bash: 在容器里启动 shell.

也可以使用更清晰的 --mount 写法:

docker run --rm -it \
    --mount type=bind,source="$PWD",target=/work \
    -w /work \
    ubuntu:24.04 \
    bash

只读挂载:

docker run --rm \
    -v "$PWD:/work:ro" \
    -w /work \
    ubuntu:24.04 \
    ls

:ro 表示 read-only, 容器不能修改这个目录. 如果你只是让容器读取数据或源码, 只读挂载更安全.

Volume 适合保存数据库数据或构建缓存:

docker volume create mydata
docker run -d \
    --name db \
    -v mydata:/var/lib/postgresql/data \
    postgres:16

查看 volume:

docker volume ls
docker volume inspect mydata

文件权限是挂载里最常见的坑. Linux 上 bind mount 后, 容器和宿主机看到的是同一批文件, 权限按 UID/GID 判断. 如果容器用 root 写文件, 宿主机上可能变成 root 文件. 如果容器用 UID 1000 写文件, 宿主机上 UID 1000 的用户就会拥有它, 即使容器里这个用户没有名字.

macOS/Windows 上还有一层 Docker Desktop 的文件共享映射, 所以权限和性能表现可能和 Linux 服务器不同. 如果你的项目最终跑在 Linux 服务器上, 涉及挂载和权限时最好在 Linux 上再验证一次.

端口映射

容器默认有自己的网络命名空间. 容器里服务监听的端口, 宿主机不一定能直接访问. 需要使用 -p 做端口映射.

运行 nginx:

docker run --rm -d --name web -p 8080:80 nginx

这里 -p 8080:80 的含义是:

宿主机 8080 端口 -> 容器 80 端口

然后访问:

curl http://localhost:8080

指定只监听本机地址:

docker run --rm -d --name web -p 127.0.0.1:8080:80 nginx

这样只有宿主机本机能访问 127.0.0.1:8080, 外部机器不能直接访问. 这比默认绑定到所有网卡更安全.

查看端口映射:

docker port web
docker ps

常见错误是服务在容器里只监听 127.0.0.1. 如果容器内应用只绑定 localhost, Docker 端口映射可能访问不到它. 对 Web 服务来说, 容器内通常应监听 0.0.0.0, 然后由 Docker 决定宿主机如何映射.

例如 Python HTTP server 默认通常监听所有地址:

docker run --rm -p 8000:8000 python:3.12 \
    python -m http.server 8000

运行交互式容器

交互式容器常用于临时调试环境:

docker run --rm -it ubuntu:24.04 bash

选项含义:

  • -i: 保持 stdin 打开.
  • -t: 分配伪终端.
  • --rm: 容器退出后自动删除.

进入后你可以像在一台 Linux 机器里一样运行命令:

apt update
apt install -y build-essential
gcc --version

但要记住, 如果没有挂载 volume 或 bind mount, 容器删除后这些安装和改动都会消失.

给容器挂载当前目录:

docker run --rm -it \
    -v "$PWD:/work" \
    -w /work \
    ubuntu:24.04 \
    bash

如果容器已经在后台运行, 可以用 exec 进入:

docker exec -it <container> bash

docker run 是创建并启动新容器, docker exec 是在已有容器中再启动一个进程. 这两个命令不要混淆.

以特权模式运行容器

--privileged 会显著放宽容器隔离, 给容器更多 Linux capabilities, 设备访问权限, 并允许一些原本被限制的内核操作.

docker run --rm -it --privileged ubuntu:24.04 bash

它常用于需要访问硬件设备或底层系统能力的场景, 例如:

  • 在容器里操作某些 /dev 设备.
  • 嵌入式开发中访问 USB/JTAG 设备.
  • 在容器里运行 Docker 或容器运行时实验.
  • 需要加载内核相关功能的特殊调试环境.

但 --privileged 基本上意味着容器不再是普通意义上的隔离环境. 如果再配合挂载宿主机目录, 容器内进程可能对宿主机造成严重影响.

更推荐的做法是只授予需要的能力或设备:

docker run --rm -it \
    --device /dev/ttyUSB0:/dev/ttyUSB0 \
    ubuntu:24.04 \
    bash

或者添加特定 capability:

docker run --rm -it \
    --cap-add NET_ADMIN \
    ubuntu:24.04 \
    bash

基本原则是: 能用 --device 就不要用 --privileged; 能加一个 capability 就不要全量放开.

Dockerfile 和镜像构建

Dockerfile 是描述如何构建镜像的文件. 一个最小例子:

FROM ubuntu:24.04

RUN apt-get update && apt-get install -y \
    build-essential \
    cmake \
    git \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /work
CMD ["bash"]

构建镜像:

docker build -t my-dev:latest .

这条命令里的 . 很重要, 它表示构建上下文 (Build Context) 是当前目录. Docker build 大致会按下面的流程执行:

  1. 读取当前目录下的 Dockerfile.
  2. 根据 .dockerignore 排除不需要发送给 Docker daemon 的文件.
  3. 把构建上下文发送给 Docker daemon.
  4. 从 FROM 指定的基础镜像开始.
  5. 按 Dockerfile 从上到下逐条执行指令.
  6. 对会改变文件系统的指令生成新的 layer.
  7. 最后把得到的镜像打上 -t 指定的 tag.

可以把 Dockerfile 想成一份"制作镜像的菜谱". docker build 不会直接在你的宿主机系统里执行这些命令, 而是在构建环境中基于上一层镜像状态执行. 例如 RUN apt-get install ... 安装的是镜像里的软件, 不是安装到你的宿主机.

构建上下文决定了 COPY 能看到哪些文件. 例如:

COPY requirements.txt .
COPY src/ ./src/

这里的 requirements.txt 和 src/ 必须在 build context 里面. 如果你运行:

docker build -t my-app .

那么当前目录就是 build context. 如果你运行:

docker build -t my-app ..

那么上一级目录才是 build context, COPY 的源路径也会相对于上一级目录解释.

.dockerignore 的作用类似 .gitignore, 但它影响的是 build context. 例如:

.git
build
node_modules
*.log

这些文件不会被发送给 Docker daemon, 也不能被 COPY 进镜像. 这可以减少构建上下文大小, 避免把无关文件, 缓存, 密钥误打进镜像.

Docker 的构建缓存也是按指令顺序工作的. 对于每条指令, Docker 会检查这条指令和它依赖的输入是否和上次相同. 如果相同, 就复用旧 layer; 如果不同, 这一层以及后面的层通常都要重新构建.

例如:

COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

如果只修改源码, 前两条通常能复用缓存, 只需要重新执行 COPY . . 之后的部分. 如果修改了 requirements.txt, pip install 这一层就会重新执行.

有时你想确认构建不使用缓存:

docker build --no-cache -t my-app .

如果 Dockerfile 不在默认位置, 可以用 -f 指定:

docker build -f docker/Dockerfile -t my-app .

注意这里 -f 指定的是 Dockerfile 路径, 最后的 . 仍然是 build context. 这两个概念不要混淆.

运行镜像:

docker run --rm -it my-dev:latest

常见指令:

  • FROM: 指定基础镜像.
  • RUN: 构建镜像时执行命令.
  • COPY: 把构建上下文中的文件复制进镜像.
  • WORKDIR: 设置工作目录.
  • ENV: 设置环境变量.
  • ARG: 构建参数.
  • CMD: 容器默认启动命令.
  • ENTRYPOINT: 容器入口程序.
  • USER: 指定运行用户.

RUN, CMD, ENTRYPOINT 容易混淆:

  • RUN 在 docker build 时执行, 结果写入镜像.
  • CMD 在 docker run 时作为默认命令, 可以被命令行参数覆盖.
  • ENTRYPOINT 更像固定入口, 命令行参数通常会追加给它.

例如 Python 项目:

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

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

构建并运行:

docker build -t my-python-app .
docker run --rm my-python-app

构建上下文是 docker build 最后那个路径, 例如上面的 .. Docker 只能 COPY 构建上下文里的文件. 如果项目目录很大, 应该写 .dockerignore 排除不需要的文件:

.git
build
__pycache__
*.log

为了避免容器里默认 root 运行, 可以创建普通用户:

FROM ubuntu:24.04

RUN useradd -m -u 1000 appuser
USER appuser
WORKDIR /home/appuser
CMD ["bash"]

这能减少容器进程对文件系统的破坏面, 也能缓解 bind mount 时的权限问题.

进阶: 分层构建

Docker 镜像由多层只读 layer 组成. Dockerfile 中很多指令都会创建新层, 例如 RUN, COPY, ADD. 构建时 Docker 会缓存已有层, 如果某一层没有变化, 下次构建可以直接复用.

Layer 不只是构建缓存, 也影响分发体积. 当你 push 或 pull 镜像时, Registry 传输的是一层一层的内容. 如果两张镜像共享相同基础层, 客户端已经有这些层时就不需要重复下载. 例如多个镜像都基于 ubuntu:24.04, 那么基础系统层可以复用.

但 layer 也有一个重要特性: 后面的层删除前面层里的文件, 不会让前面的层变小. 它只是记录"这个文件在最终视图里被删除了". 因此, 想减小分发镜像体积, 应该避免把不需要分发的文件写进任何会保留到最终镜像的 layer.

例如:

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

这里先复制 requirements.txt, 安装依赖, 最后再复制源码. 这样如果你只改了源码, pip install 那一层仍然可以复用, 构建会快很多.

如果写成:

FROM python:3.12-slim

WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "main.py"]

那么任何源码改动都会导致 COPY . . 这一层变化, 后面的 pip install 也会重新执行.

APT 安装依赖时, 常见写法是把 update, install, cleanup 放在同一个 RUN 里:

RUN apt-get update && apt-get install -y \
    build-essential \
    cmake \
    && rm -rf /var/lib/apt/lists/*

如果分成多个 RUN, 清理命令可能不能减少前面 layer 已经写入的体积.

例如下面这种写法并不能有效缩小镜像:

RUN apt-get update
RUN apt-get install -y build-essential cmake
RUN rm -rf /var/lib/apt/lists/*

因为 apt 索引已经写进了前面的 layer, 后面删除只是在新 layer 里标记删除. 正确做法是把安装和清理放在同一个 RUN 中, 让不需要的中间文件从未进入最终 layer.

多阶段构建可以把编译环境和运行环境分开. 例如 C++ 程序:

FROM ubuntu:24.04 AS builder
RUN apt-get update && apt-get install -y g++ cmake && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . .
RUN cmake -S . -B build && cmake --build build

FROM ubuntu:24.04
COPY --from=builder /src/build/my_app /usr/local/bin/my_app
CMD ["my_app"]

最终镜像只包含运行程序, 不包含完整编译工具链. 这能减小镜像体积, 也能减少运行时攻击面.

多阶段构建是减小分发镜像体积最重要的方法之一. 编译时需要的内容通常很多, 例如编译器, CMake, header, 静态库, 源码, 测试数据. 运行时可能只需要一个二进制文件和少量动态库. 多阶段构建把这些内容分开: builder stage 可以很大, final stage 只复制真正要分发的产物.

这对分发特别重要. 开发镜像可以保留工具链方便调试; 分发镜像应尽量只包含运行时依赖, 这样 pull 更快, 存储更少, 暴露的工具也更少.

查看镜像历史:

docker history my-app:latest

这可以帮助你理解每一层大概来自哪条 Dockerfile 指令.

Docker 镜像仓库

镜像仓库 (Registry) 用来保存和分发镜像. 常见公共仓库包括 Docker Hub, GitHub Container Registry, GitLab Container Registry 等.

镜像名通常由几部分组成:

registry/namespace/name:tag

例如:

docker.io/library/ubuntu:24.04
ghcr.io/user/project:latest
registry.example.com/team/app:v1.0.0

如果省略 registry, Docker 默认使用 Docker Hub. 如果省略 tag, 默认使用 latest, 但生产环境不建议依赖 latest, 因为它不是稳定版本号.

登录仓库:

docker login
docker login ghcr.io

给镜像打 tag:

docker tag my-app:latest ghcr.io/user/my-app:v1.0.0

推送镜像:

docker push ghcr.io/user/my-app:v1.0.0

拉取镜像:

docker pull ghcr.io/user/my-app:v1.0.0

查看镜像详细信息:

docker image inspect my-app:latest

镜像 tag 只是名字, 不是内容本身. 真正唯一标识镜像内容的是 digest, 例如 sha256:.... 如果你需要完全可复现的环境, 可以使用 digest 固定镜像:

docker pull ubuntu@sha256:<digest>

对课程项目或论文 artifact 来说, 尽量不要只写 ubuntu:latest, 而应使用明确版本, 例如 ubuntu:24.04 或自己的固定 tag.

进阶: Docker Compose

Docker Compose 用来管理多容器应用. 如果一个项目需要 Web 服务, 数据库, Redis, 消息队列等多个容器, 手写一长串 docker run 会很难维护, Compose 可以把这些配置写进一个 YAML 文件.

一个简单例子:

services:
  web:
    build: .
    ports:
      - "8080:80"
    volumes:
      - .:/app
    depends_on:
      - redis

  redis:
    image: redis:7

启动:

docker compose up

后台启动:

docker compose up -d

查看日志:

docker compose logs
docker compose logs -f web

进入某个服务容器:

docker compose exec web bash

停止并删除容器和默认网络:

docker compose down

如果还想删除 volume:

docker compose down -v

down -v 会删除数据卷, 数据库数据可能丢失, 使用前要确认.

Compose 会自动为同一个项目里的服务创建网络, 服务之间可以用服务名访问. 例如上面的 web 可以通过 hostname redis 访问 Redis.

一个带环境变量和 volume 的例子:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
      POSTGRES_DB: app
    volumes:
      - db-data:/var/lib/postgresql/data
    ports:
      - "127.0.0.1:5432:5432"

volumes:
  db-data:

这里 db-data 是 Docker 管理的 volume, 容器删除后数据仍然存在. 端口映射使用 127.0.0.1:5432:5432, 表示数据库只暴露给本机访问.

对于初学者来说, 读 Compose 文件时可以先看四件事:

  • image 或 build: 服务从哪里来.
  • ports: 哪些端口暴露到宿主机.
  • volumes: 哪些文件或数据会持久化.
  • environment: 服务依赖哪些环境变量.