Docker 镜像基础:分层原理与常用操作

镜像这东西,说白了就是容器的“预制菜”。你拉下来就能用,但背后怎么存、怎么节省磁盘——这些基础不打好,后面搭计算环境早晚要翻车。我刚开始用 Docker 时,一口气 pull 了三个 Ubuntu 镜像,发现居然占了不到 500MB?当时我还以为搞错了。嗯,这就得从分层原理讲起。

镜像分层:Union FS 的思想

Docker 镜像不是一整块胖文件,而是由一堆只读层堆叠起来的。每一层对应 Dockerfile 里的一条指令(RUN、COPY、ADD 等)。所有层通过 Union FS(联合文件系统)挂载成一个完整的文件系统视图。

举个例子,你写个 Dockerfile:

FROM ubuntu:22.04
RUN apt update && apt install -y python3
COPY app.py /app
CMD ["python3", "/app/app.py"]

这个镜像会被拆成三层:

  • 基础层:ubuntu:22.04 本身就是一个多层堆叠(比如 base OS、libc、包管理工具)。
  • 依赖安装层:RUN 命令产生的新层,包含 python3 及依赖。
  • 应用代码层:COPY app.py 产生的层。

关键点:所有层都是只读的。当你用这个镜像启动容器时,Docker 会在最上面加一个可写层(容器层)。你对容器做的任何修改(写文件、装包、改配置)都只写在这一层。容器删掉后,这层也跟着消失,镜像原封不动。

我曾在项目里遇到过这种场景:团队 A 用 ubuntu:18.04 搭了个包含 CUDA 和 cuDNN 的镜像,团队 B 直接用 FROM teamA/image 加上自己的代码,只多了一层。整个镜像大小只多了几十 MB,这就是分层复用的威力。

分层的好处很明显:重复利用共享层。你 pull 一个镜像时,Docker 会比对本地已有的层,只下载缺失的部分。我第一次 pull nvidia/cuda:11.8-devel 时花了很久,但第二次 pull 一个基于相同基础镜像的版本,几乎是瞬间完成——因为底层已经在本地了。

镜像分层结构图

下面这张图概括了镜像与容器层的堆叠关系:

Docker 镜像分层模型 可写层(容器层) 容器运行时修改 应用代码层 COPY app.py 依赖安装层 apt install python3 基础层(OS) ubuntu:22.04 底层内核层 host kernel shared 容器可写层 应用代码层 依赖安装层 基础OS层 kernel层(只读) 镜像只读层 + 容器可写层 = 容器完整文件系统

镜像常用命令:pull、images、rmi

先列个表,快速对照三个命令的用途:

命令作用典型场景
docker pull从仓库下载镜像到本地拉取基础镜像或预构建环境
docker images列出本地所有镜像及标签查看磁盘占用、查找镜像 ID
docker rmi删除本地镜像(一层层删)清理废弃镜像,释放空间

docker pull

用法很简单:

docker pull ubuntu:22.04

不加标签默认拉 :latest。但我建议你永远指定具体标签,不然哪天 latest 变了,你镜像就不可重复了。我在项目中吃过这个亏——脚本里用了 ubuntu:latest,三个月后 build 就炸了,因为基础包版本变了。

pull 时 Docker 会显示每一层的 SHA256 和下载进度。如果网络慢,可以配置 registry-mirrors 到国内的阿里云或中科大镜像:

# /etc/docker/daemon.json
{
  "registry-mirrors": ["https://docker.mirrors.ustc.edu.cn"]
}

docker images

列出本地镜像:

docker images

输出大概这样:

REPOSITORY    TAG       IMAGE ID       CREATED        SIZE
ubuntu        22.04     a0b2b1c2d3e4   2 weeks ago    78MB
nvidia/cuda   11.8-devel   f1e2d3c4b5a6   1 month ago   3.2GB

常用参数:

  • --digests:显示镜像摘要(SHA256),用于精确版本锁定。
  • --filter "dangling=true":列出无标签的中间层镜像(也称为悬空镜像)。

关键认知:docker images 显示的 SIZE 是每层压缩后大小之和,但磁盘实际占用可能更小,因为层之间共享数据块。要看到真实占用,可以用 docker system df。

docker rmi

删除镜像:

docker rmi ubuntu:22.04

或者用 ID 删除:

docker rmi a0b2b1c2d3e4

避坑:如果某个镜像还被某容器引用(即使是停止的容器),Docker 会拒绝删除并报错。你需要先 docker rm 容器,或者加 -f 强制删除(慎用)。

我曾经在 CI 机器上执行 docker rmi $(docker images -q) 想清空所有镜像,结果发现正在运行的容器因为底层镜像被卸载直接崩溃了。千万不要在生产环境上这么玩。正确做法:docker image prune -a 会跳过正在使用的镜像。

镜像仓库登录与推送

镜像仓库就是存放镜像的“GitHub”,可以是 Docker Hub、GitLab Container Registry,或者自建的 Harbor。你要推送私人镜像,必须先登录。

docker login

登录 Docker Hub:

docker login

它会提示输入用户名和密码。登录后凭证保存在 ~/.docker/config.json 里。注意密码不要明文写进脚本,我用过 docker login -u myuser -p mypass 的方式,结果 history 里全暴露了。推荐用 docker login --password-stdin 从文件读:

cat my_password.txt | docker login --username myuser --password-stdin

登录私有仓库:

docker login myregistry.example.com:5000

如果仓库用了自签名证书,记得把 CA 证书放到宿主机的 /etc/docker/certs.d/myregistry.example.com:5000/ca.crt 里,否则登录会报 x509: certificate signed by unknown authority。

docker push

推送之前先给镜像打上仓库地址的标签:

docker tag myapp:latest myregistry.example.com:5000/myapp:1.0
docker push myregistry.example.com:5000/myapp:1.0

push 时同样逐层上传,只会上传本地不存在的层。这个特性在 CI 中尤其重要——如果你改了代码但基础镜像没变,push 只上传新的一层,省时省带宽。

我个人习惯在 Dockerfile 里把 apt install 和 pip install 放在单独一行,这样如果以后只改代码,依赖层不会变,push 和 pull 都更快。你要知道,GPU 计算镜像往往几 GB,多一层就是几分钟的差异。

docker logout

用完记得登出,特别是共享服务器上:

docker logout
docker logout myregistry.example.com:5000

登出后凭证从 ~/.docker/config.json 移除。

实战小清单:镜像生命周期管理

我把自己每天操作的步骤整理成了清单,你照着来基本不会出问题:

  1. 拉取基础镜像:docker pull python:3.10-slim(指定具体版本)。
  2. 构建本地镜像:docker build -t myenv:1.0 .。
  3. 列出并检查:docker images 确认镜像存在,docker history myenv:1.0 查看各层大小。
  4. 登录仓库:docker login 或 docker login myrepo.com。
  5. 打标签推送:docker tag myenv:1.0 myrepo.com/team/myenv:1.0,然后 docker push。
  6. 清理本地废弃镜像:docker image prune -f 删除悬空镜像。

这套流程我在多个项目里验证过,包括大型 GPU 集群的镜像分发。记住一点:镜像标签是契约,一旦推送出去别轻易改内容。用 --digests 锁定 SHA 更可靠。