2
0
0

02-Docker 镜像与数据卷:分层结构、挂载与 Dockerfile

2026-10-22
2026-10-22

📝 笔记摘要:Docker 镜像与数据卷,分层结构、挂载与 Dockerfile

镜像为什么能做得那么小?两个镜像为什么能共用一份数据?容器删了,里面写的东西去哪了?这些问题的答案都在两个词里:分层和挂载。

这篇把镜像的内部结构讲透,再把 Dockerfile 从头到尾过一遍——哪条指令在构建期跑、哪条在运行期跑、哪些指令已经废弃了。最后说清楚怎么把自己做的镜像推到云上仓库去。

Docker 系列 共 4 篇,这是第 2 篇
  01-Docker 基础 安装、概念与容器操作
  02-Docker 镜像与数据卷 分层结构、挂载与 Dockerfile ← 本篇
  03 Docker 网络与 Compose 容器互访与多容器编排
  04 Docker 常见问题排查 守护进程、权限与镜像拉取

1. 镜像从哪来

从仓库拉 docker pull nginx:1.27.5
  最常见的一种,标签去 Docker Hub 上查
别人给你一份 docker load -i xxx.tar
  离线环境,或者同事打包给你
自己 build 出来 docker build -t 名字:标签 .
  有 Dockerfile 就能造,这是唯一能进版本管理的路子

「镜像是模板」这个说法只对了一半。镜像是一叠只读层,这个结构决定了它的大小、共享方式,也决定了容器里的东西会不会丢。

2. 分层结构:为什么镜像这么小

镜像分层结构示意图:左右两个镜像 nginx 1.27 与 nginx 1.27-alpine 并排,中间绿色的依赖层标记为共享表示磁盘上只存一份;下方是同一镜像跑成容器后额外叠了一层容器可写层,标注写时复制,容器删除时这层一起消失

层是按内容哈希存的
  镜像里记的是「这一层的内容是什么」,不记「这一层属于谁」
  两个镜像用了同一层,磁盘上就是同一份数据
拉取时只补缺的层
  本地已有 nginx 的基础层,再拉别的镜像只下差异部分
  这就是为什么「第一个镜像慢,第二个快」
容器在镜像之上加一层可写层
  容器里新写的文件落在这层,镜像本身一个字节都不会变
  读的时候读下层,只有改动的那部分复制到可写层(写时复制)
所以「改一行配置重新 build 一个大镜像」是个错觉
  commit 出来的新镜像底下压着原镜像的全部层
  真正占空间的是基础层,而基础层是大家共享的

想让自己的镜像小一点,两条路:基础镜像换成 alpine / -slim 变体,或者用多阶段构建只保留运行时需要的东西。

3. docker commit

容器里改了东西,想把它固化成镜像,用 commit:

docker commit -a "你的名字" -m "这次改了什么" 容器名 新镜像名:标签

commit 出来的是「你改完的样子」,不是 Dockerfile
  它记录的是当前可写层的实际内容,过程不可复现
  换台机器 rebuild 一次,结果可能不一样
commit 的正确用法是应急和留档
  临时改了个配置、验证过了,打个 tag 存下来
  正式交付的东西都应该有 Dockerfile
commit 会把密钥一起提交进去
  你在容器里 export 了环境变量、改了配置文件,全都在这层里
  推到仓库之前最好 docker history 看一眼
体积也会变大
  容器里装过的临时文件、编译产物,全被固化进去了

4. 数据卷:容器里的东西存哪

容器是「用完即弃」的。容器里写进去的文件,默认跟着容器一起没了。要让数据活过容器,就得把它放到容器外面。

三种挂载方式对照图:绑定挂载指向宿主机真实目录、具名卷由 Docker 托管目录、临时挂载落在内存;下方列出挂载时的三条目录覆盖规律,两边都没有会自动创建、宿主有容器没有则整个映射、容器有宿主没有则以宿主目录为准

绑定挂载 -v /host/path:/container/path
  数据在宿主机上那个真实目录,宿主机改了容器里立刻看得见
  适合:配置文件、代码目录
具名卷 -v mydata:/container/path
  数据在 Docker 管的目录里,路径不用自己记
  适合:数据库数据、上传文件
不写名字就是匿名卷
  Docker 自动造个随机名字,容器删掉时默认一起清理
  想让数据活下来就得自己起名字
三种方式里,tmpfs 是落在内存里的
  容器一删数据就没,适合临时文件和密钥
卷的真实位置
  /var/lib/docker/volumes
  删容器不等于删卷,docker volume rm 才是真删

5. 挂载时的三条覆盖规律

这三条是挂载最容易踩的坑,尤其第三条:容器里明明有文件,挂进去就「不见」了。

两边都没有 → Docker 自动创建
  宿主机和容器里这个路径都不存在,会被创建出来,是空目录,权限属 root
  想在里面写文件,宿主机的普通用户可能没权限
宿主有、容器没有 → 整个映射进去
  宿主机目录连同里面的文件整体映射进容器,容器里立刻能看见
容器有、宿主没有 → 以宿主为准
  容器里原来的内容被挡住,看不见了
  宿主目录是空的,就等于空目录
第三条最容易出事
  镜像里预置了 /app/conf/app.yml,宿主机上 /data/conf 是空目录
  挂上去以后容器里就看不到镜像自带的配置了,报「配置文件不存在」
  代码本身没问题,文件一直都在,只是被挡住了
  排查这类现象先看 docker inspect 里的挂载点,别急着怀疑镜像

docker volume ls                    # 看所有卷,包括匿名的
docker inspect 容器名 | grep -A 5 Mounts   # 看这个容器挂了哪些
docker volume rm 卷名               # 真删卷,数据不可恢复

6. Dockerfile 是什么

Dockerfile 是一个文本文件,每一行是一条构建指令。它描述「这个镜像从哪个基础镜像来、要装什么、要拷贝什么、启动时跑什么」,然后 docker build 按顺序执行。

FROM redis:7-alpine
LABEL maintainer="yourname <you@example.com>"
ENV MYPATH /data
WORKDIR $MYPATH
RUN apk add --no-cache vim
EXPOSE 6379
CMD ["redis-server", "/etc/redis/redis.conf"]
docker build -f /data/dockerfile/Dockerfile -t test/redis:1.0 .

末尾那个点不能省
  它是「构建上下文」的路径,Docker 从这个目录里找 Dockerfile 和要 COPY 的文件
  写 docker build . 就是拿当前目录当上下文
  上下文路径不对,COPY 就会失败
-f 指定 Dockerfile 的位置,-t 指定镜像名和标签
  Dockerfile 放在当前目录时可以省略 -f
构建上下文不要太大
  . 指向的目录里所有文件都会打包发给守护进程
  放个 .dockerignore 排除掉不该传的
  没有 .dockerignore 的话,几 G 的 node_modules 能把构建拖死

7. 指令逐条

FROM 基础镜像,必有,且必须是第一条
  写 FROM redis:7-alpine,不是 FROM redis
  多阶段构建时可以用 AS 别名 命名阶段
LABEL 标签
  写作者、版本、说明,机器可读
  老写法 MAINTAINER 已经废弃,现在用 LABEL maintainer="..."
ENV 环境变量
  构建期和运行期都能用,ENV MYPATH /data 相当于 key value
  后面可以引用:WORKDIR $MYPATH
WORKDIR 工作目录
  会创建目录,后续的 COPY、RUN 都在这个目录下执行
RUN 构建期执行命令,产生新的一层
  每条 RUN 一层,层是只读的,所以同一条 RUN 里装完立刻清理才省空间
  RUN 是构建时跑,CMD 和 ENTRYPOINT 才是运行时
COPY 把文件复制进镜像
  不会自动解压,压缩包进去还是压缩包
ADD 也是复制文件,多一个自动解压本地 tar 的能力
  ADD 加的是文件,不是镜像——想把别的镜像塞进来得用 FROM 叠加
  能用 COPY 就别用 ADD,行为更可预测
VOLUME 声明挂载点
  只是声明,实际路径靠 docker run -v 给
EXPOSE 声明容器内部会用的端口
  它只是文档性质的,不等于 -p
  不写 EXPOSE,-p 照样能用;写了 EXPOSE,不加 -p 外部照样访问不了
  真正的端口发布靠 docker run -p 或 compose 的 ports
CMD 默认启动命令
  可以被 docker run 后面的参数整个替换掉
ENTRYPOINT 固定启动命令
  不可被替换,run 后的参数接在它后面
ONBUILD 触发指令
  当前镜像构建时不执行,当别的镜像以它为基础镜像时自动执行
  用得很少,理解为「给下游准备的钩子」

CMD 和 ENTRYPOINT 的组合规则最容易搞混,单独画一张图:

CMD 与 ENTRYPOINT 组合规则表:只有 CMD 时 run 参数整体替换 CMD,只有 ENTRYPOINT 时参数追加在后面,两个都有时只替换 CMD 部分,两个都没有时替换基础镜像自带命令;下方补充 shell 形式与 exec 形式的区别

写 Dockerfile 默认用 exec 形式(方括号)
  CMD ["java", "-jar", "app.jar"]
  shell 形式会把整条字符串交给 /bin/sh -c,多出来的参数传不进主程序
  需要 shell 变量、管道的时候,显式写成 CMD ["/bin/sh", "-c", "..."]
exec 形式还有个好处:信号能直接送到主进程
  shell 形式多了一层 sh,docker stop 的信号先到 sh 再转给 java,关容器会慢好几秒
要加参数就写进 CMD,用 ARG 传进去
  比拼字符串给 docker run 干净

8. 实战:造一个自己的镜像

FROM ubuntu:22.04
LABEL maintainer="yourname <you@example.com>"
ENV MYPATH /data
WORKDIR $MYPATH
RUN apt-get update \
    && apt-get install -y --no-install-recommends vim net-tools \
    && rm -rf /var/lib/apt/lists/*
EXPOSE 8090
CMD ["/bin/bash"]
docker build -f /data/dockerfile/Dockerfile -t myubuntu:1.0 .
docker run -itd --name mytest -p 8090:8090 myubuntu:1.0
docker exec -it mytest bash
ls /data          # WORKDIR 生效了

装完包顺手清缓存
  rm -rf /var/lib/apt/lists/* 或者 apk cache clean
  不清的话 apt 索引会占镜像体积的三分之一
基础镜像别选太老的
  centos:7 是 CentOS 7 EOL(2024-06-30)
  官方 centos 镜像已经整体 DEPRECATED,最后一个有意义的更新停在 2020 年 11 月
  镜像能拉下来,但里面的 yum 源是死的,装任何包都会报 Could not resolve host: mirrorlist.centos.org
  新项目用 Rocky Linux 9 或者 AlmaLinux 9,跟 CentOS 7 二进制兼容,迁移成本很低
实在要留着 centos 7,得在 Dockerfile 里改源
  RUN sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-*.repo
  RUN sed -i 's|#baseurl=http://mirror.centos.org|baseurl=https://mirrors.aliyun.com/centos-vault|g' /etc/yum.repos.d/CentOS-*.repo
  然后 yum clean all && yum -y install vim && yum clean all
Java 基础镜像别再用 openjdk
  FROM openjdk:8 现在会直接失败,报 manifest unknown
  官方 openjdk 镜像已废弃,Docker Hub 上只剩 EA 预览版标签,8 / 11 / 17 这些稳定标签都被移除了
  换 Eclipse Temurin:FROM eclipse-temurin:8-jre
  生产用 -jre 就够了,编译用 -jdk

9. 看构建历史

docker history 镜像名
docker history --no-trunc 镜像名    # 显示完整命令,不截断

每一行就是一层
  能看出这个镜像是怎么一层层堆出来的
用它来验证「这个镜像是怎么来的」
  扫一眼就知道有没有塞进不该有的东西
层的大小能看出哪里浪费了
  某层几十 M,多半是忘了清缓存
用 BuildKit 构建时行数会变少
  现在 docker build 默认走 BuildKit,会把连续的 RUN 合并成一层
  所以层数比老文章里说的少,不代表写错了
  docker buildx version 可以确认有没有装上

10. 造一个 Tomcat 镜像

比手搓 JDK + Tomcat 更省事的做法:直接用 JRE 底子,把 Tomcat 放进去。

FROM eclipse-temurin:8-jre
WORKDIR /app
COPY apache-tomcat-9.0.98 /app/tomcat
ENV CATALINA_HOME=/app/tomcat
ENV PATH=$CATALINA_HOME/bin:$PATH
EXPOSE 8080
CMD ["catalina.sh", "run"]
docker build -f /data/mytomcat/Dockerfile -t diytomcat:1.0 .

docker run -itd -p 9090:8080 --name mytomcat \
  -v /data/tomcat/test:/app/tomcat/webapps/test \
  -v /data/tomcat/logs:/app/tomcat/logs \
  diytomcat:1.0

用 catalina.sh run,不要用 startup.sh
  startup.sh 会把 Tomcat 扔到后台,容器主进程立刻退出,容器就停了
  老写法 CMD startup.sh && tail -F catalina.out 是拿 tail 冒充主进程
  能跑,但多一个进程、信号也传不到 Tomcat
  catalina.sh run 让 Tomcat 就在前台跑,这才是容器该有的样子
别用 ADD 塞 tar.gz
  ADD 确实会自动解压,但行为不好预测
  本地先解开再 COPY 目录,行为明确
Tomcat 的日志和 webapps 都要挂出来
  不挂出来,容器一删日志和部署的 war 都没了
挂 webapps 的子目录而不是整个 webapps
  整个挂上去,镜像里自带的默认应用全被挡住(就是第 5 节那第三条规律)
  只挂你要的那个应用目录,冲突最小
JRE 底子不带调试工具
  要 jmap、jstack 就换成 -jdk 变体

11. 推到云上的镜像仓库

自己做的东西要给别人用,或者要跨机器部署,就得推到一个仓库里。

# 1. 登录
docker login registry.cn-hangzhou.aliyuncs.com

# 2. 打标签,把本地镜像指向远程地址
docker tag diytomcat:1.0 registry.cn-hangzhou.aliyuncs.com/你的命名空间/diytomcat:1.0

# 3. 推
docker push registry.cn-hangzhou.aliyuncs.com/你的命名空间/diytomcat:1.0

命名空间要先在控制台建好
  阿里云 → 容器镜像服务 → 命名空间
  推送不会自动创建命名空间,硬推会报仓库不存在
标签是三段式的
  registry.cn-hangzhou.aliyuncs.com/命名空间/仓库名:标签
  少了命名空间这一段,推不上去
tag 这一步是必须的
  本地镜像叫 diytomcat:1.0,远程要 registry.../diytomcat:1.0
  直接 docker push diytomcat:1.0 会推去 Docker Hub,不是你以为的地址
推上去之前想验一遍
  docker pull registry.cn-hangzhou.aliyuncs.com/你的命名空间/diytomcat:1.0
  拉通了再删本地镜像重新拉一遍,验证是完整的
别用 latest 当业务镜像的标签
  latest 是会动的指针,别人拉到的可能跟你本地测的不是一回事
  用 1.0、20261022 这种钉死的版本
国内拉不动的话,加速地址就在同一个控制台
  容器镜像服务 → 镜像工具 → 镜像加速器
  见 Docker 基础 那篇的配置方式

想自建一个仓库中转站,把 Docker Hub 反代到自己的域名下,见 docker 搭建 docker 仓库代理 那篇。

12. 离线搬运:save 和 load

服务器连不上外网,但你有另一台能上网的机器,镜像可以打包装过去。

# 在有网的那台上
docker pull nginx:1.27.5
docker save -o /data/nginx_1.27.5.tar nginx:1.27.5

# 传过去(scp / ftp / U 盘都行)

# 在目标机器上
docker load -i /data/nginx_1.27.5.tar
docker images | grep nginx

docker save 存的是镜像,docker export 存的是容器
  save 保留全部层和元数据,load 回来还是一个完整的镜像
  export 把容器文件系统拍平成一个 tar,历史、层结构、CMD 全部丢失
  要搬镜像就用 save,不要用 export
load 完不用重新 pull
  它已经进了本地镜像库,docker images 能直接看到
要搬的是「改了之后的容器」
  先 docker commit 成镜像,再 save
省空间的办法
  docker save xxx.tar | gzip > xxx.tar.gz 压缩
  docker load -i xxx.tar.gz Docker 能自动识别 gzip
大批量搬运用 registry
  几十个镜像一个一个 save 传过去太慢,推到私有仓库再 pull 更快
注意镜像架构
  x86 的机器上 build 出来的镜像,在 arm 机器上(树莓派)跑不了
  docker image inspect 镜像名 --format '{{.Architecture}}' 查一下

💡 补充几个容易忽略的:
Dockerfile 也该进 Git
  镜像可以重建,Dockerfile 才是可复现的来源
  把它和代码放一起,版本一起管
构建缓存能省大量时间
  Dockerfile 里的指令一行一个层,改一行就只重跑后面几行
  把变化最少的指令放前面,比如先 COPY 依赖清单再装依赖
多阶段构建能把编译产物留在构建阶段
  编译用 -jdk,运行用 -jre,最终镜像里没有编译器
  攻击面小一大截
Docker Compose 是 Dockerfile 的搭档
  一个 Dockerfile 管一个镜像,多个容器怎么连起来是 Compose 的事
  见 Docker 网络与 Compose 那篇
私有仓库的坑
  默认的 registry:2 没有认证也没有界面
  生产环境用 Harbor,有用户管理、漏洞扫描、镜像签名
镜像安全扫描可以本地跑
  docker scout cves 镜像名 Docker 自带的
  Trivy 是另一个常用的开源工具,能在推之前就把高危 CVE 拦下来
Docker Content Trust 已经退役
  从 Engine 29 起 docker trust 子命令不再随 CLI 发布
  新的镜像签名方案是 Sigstore / Cosign,别再按老文章配 DCT
已有的存储方案文章可以对照看
  MinIO 自建对象存储 讲了对象存储这一层的选型和坑
  docker 搭建 alist 是拿容器跑一个网盘聚合的例子

🔥 一句话总结:镜像是一叠只读层,层按内容哈希寻址,所以能共享、能只下差异、容器加一层可写层后镜像纹丝不动;容器里的数据要活过容器就得挂出来,绑定挂载给配置文件、具名卷给数据库数据,挂载的第三条规律(宿主为空则覆盖容器内容)最容易让人以为文件丢了;Dockerfile 里 RUN 是构建期、CMD 和 ENTRYPOINT 是运行期,MAINTAINER 已废弃要用 LABEL,EXPOSE 只是文档不等于 -p,ADD 加的是文件不是镜像;基础镜像别用 openjdk(标签已移除)和 centos(整体废弃),换 Temurin 和 Rocky;Tomcat 容器用 catalina.sh run 别用 startup.sh;离线搬运用 docker save / docker load,别用 export,它会把镜像拍扁。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!