02-Docker 镜像与数据卷:分层结构、挂载与 Dockerfile
📝 笔记摘要: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 的基础层,再拉别的镜像只下差异部分
这就是为什么「第一个镜像慢,第二个快」
容器在镜像之上加一层可写层
容器里新写的文件落在这层,镜像本身一个字节都不会变
读的时候读下层,只有改动的那部分复制到可写层(写时复制)
所以「改一行配置重新 build 一个大镜像」是个错觉
commit 出来的新镜像底下压着原镜像的全部层
真正占空间的是基础层,而基础层是大家共享的
想让自己的镜像小一点,两条路:基础镜像换成 alpine / -slim 变体,或者用多阶段构建只保留运行时需要的东西。
3. docker commit
容器里改了东西,想把它固化成镜像,用 commit:
docker commit -a "你的名字" -m "这次改了什么" 容器名 新镜像名:标签
commit 出来的是「你改完的样子」,不是 Dockerfile
它记录的是当前可写层的实际内容,过程不可复现
换台机器 rebuild 一次,结果可能不一样
commit 的正确用法是应急和留档
临时改了个配置、验证过了,打个 tag 存下来
正式交付的东西都应该有 Dockerfile
commit 会把密钥一起提交进去
你在容器里export了环境变量、改了配置文件,全都在这层里
推到仓库之前最好docker history看一眼
体积也会变大
容器里装过的临时文件、编译产物,全被固化进去了
4. 数据卷:容器里的东西存哪
容器是「用完即弃」的。容器里写进去的文件,默认跟着容器一起没了。要让数据活过容器,就得把它放到容器外面。
绑定挂载
-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 的组合规则最容易搞混,单独画一张图:
写 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.gzDocker 能自动识别 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,它会把镜像拍扁。
