资讯动态

容器分层存储与持久化:Docker镜像改动如何真正保存

发布时间:2026/10/13 12:35:10 来源:尧图企业网站定制
你有没有过这种经历在容器里改了配置、装了软件包服务跑得正欢某天为了清理磁盘把容器删了再 docker run 一个同款镜像发现之前所有的改动全消失了。我第一次遇到这个问题时第一反应是 docker commit把容器“保存”成新镜像。后来在维护一个内部工具镜像的过程中反复踩坑才真正搞明白 Docker 镜像的存储机制与持久化方案。这篇文章想聊的就是云原生场景下“对 Docker 镜像的更改持久保存”这件事到底涉及哪些层面。它不是一句“用 docker commit 就行了”能带过的而是要从最底层的容器分层存储原理讲起再到 commit 的用法与代价、Dockerfile 重建、数据卷挂载最后落到 Kubernetes 里如何把“运维手工改镜像”变成“声明式更新配置”。如果你也在用 Docker 部署服务、维护基础镜像或者正在从单机 Docker 往 K8s 迁移这篇内容应该能帮你少走几条弯路。1. 改动为什么会丢镜像分层、可写层与写时复制1.1 镜像本质上是只读层的合集很多人刚接触 Docker 时会把镜像理解成“一个完整的操作系统磁盘镜像”类似 VirtualBox 里的 vdi 文件。实际上 Docker 镜像更接近“一组只读层叠起来的分层文件系统”。Dockerfile 里每一条 RUN、COPY、ADD 指令都会生成一个新的只读层最终镜像由这些层按顺序叠加而成。容器启动时Docker 会在这组只读层之上再挂载一个可写层通常叫容器层。从进程的视角看它看到的文件系统是“所有只读层 容器层”合并之后的完整目录树。这里有个关键点容器层是独立存在的跟镜像层没有耦合容器删除时这层也会跟着销毁。我习惯用一个汉堡类比来解释这件事镜像的每一层就像做汉堡时已经烤好的面包片、牛肉饼和芝士片容器层则是最后在顶部撒的酱料。汉堡可以整体展示、食用但酱料只附着在当前这一份上如果你把汉堡拆回原材料酱料自然谈不上“持久保存”。这个类比不一定完全严谨但能解释清楚为什么删除容器后改动消失。1.2 写时复制为什么修改不直接改原镜像Docker 的分层存储使用了写时复制Copy-on-Write机制。当你尝试修改一个位于只读层里的文件时Docker 不会直接去改动那个只读层——只读层是不可变的——而是先把该文件从只读层复制到可写层再在可写层中完成修改。这个机制决定了几个容易被忽略的行为修改文件会增加可写层的体积因为被修改文件的完整副本被复制到了容器层。删除只读层里的文件并不会减小镜像体积Docker 会写入一个 whiteout 标记来“遮住”只读层中的对应文件但文件实体在底层仍然存在。docker commit 产生的镜像会多出一个新层这个新层包含了容器生命周期内发生的所有文件变化痕迹。1.3 用 docker diff 实测演示改动丢失过程理解归理解最直观的还是动手测一遍。我用一个 nginx 容器做演示docker run -d --name web-test nginx:1.25 docker exec web-test bash -c echo h1hello from runtime/h1 /usr/share/nginx/html/index.html docker diff web-testdocker diff 输出会显示类似这样的行C /usr/share/nginx/html/index.htmlC 开头表示该文件在容器层中被修改了。此时如果执行 docker restart web-test改动还在因为可写层并没有被销毁。但一旦执行 docker rm -f web-test容器对象和它的可写层会一起被删除重新 docker run nginx:1.25 后回到干净状态改动彻底丢失。操作可写层是否保留容器内改动是否还在docker stop / start保留在docker restart保留在docker rm / rm -f销毁丢docker rmi 删除镜像镜像层销毁丢前提是容器已删除很多人混淆了“重启容器”和“删除容器”以为 restart 之后改动还在就等于持久化了其实那只是因为容器对象还没被清理。真正要讨论的“持久保存”必须覆盖删除容器、删除镜像、换机器拉取镜像这几种更严格的情况。1.4 可写层不适合放重要数据从存储驱动的角度看容器可写层是一个“临时且易失”的存储位置。它的设计目标是为容器提供运行时写入能力而不是为数据提供长期持久化。即使你开的是 overlay2 这种比较成熟的存储驱动可写层的生命周期也始终和容器绑定。所以这里要立下一个最基本的结论运行期产生的有价值数据不应该放在容器可写层里。镜像改动的持久化要分两类处理——一类是“程序、依赖、配置”等构建期内容走镜像重建另一类是“上传文件、数据库文件、日志”等运行期内容走数据卷挂载。后面的章节会分别展开。2. docker commit 固化镜像快照式持久化的正确打开方式2.1 commit 到底把什么保存下来了docker commit 的作用是把当前容器的可写层与原来的只读层一起打包成一个新镜像。相当于给一个正在运行的服务拍一张完整快照以后用这个新镜像启动的容器会继承拍照那一刻的文件系统状态。从技术实现上看commit 生成的镜像 Layers 数量会比原镜像多一层。你可以用 docker inspect 查看docker inspect registry.example.com/legacy-tool:v1.2.3 --format {{len .RootFS.Layers}}如果你拉取的原始镜像有 6 层commit 之后可能会变成 7 层。这多出来的一层就是容器可写层的完整内容。作为快照式持久化手段commit 在特定场景下是很有用的临时给同事保留一个排障现场让对方能直接 docker run 出跟你一样的坏环境手工安装好调试工具比如 vim、curl、tcpdump后想快速复制到另一台机器镜像仓库里没有现成环境又急需把当前容器状态保存下来。2.2 完整的 commit 流程假设你已经通过 docker exec 手工安装了一批软件包并且修改了若干配置文件现在想把它们固化下来。完整操作如下docker ps docker diff container docker commit -m feat: add curl and custom default page -a devops container registry.example.com/legacy-tool:v1.2.3 docker push registry.example.com/legacy-tool:v1.2.3push 之后在另一台机器上执行 docker pull registry.example.com/legacy-tool:v1.2.3 并 docker run就能得到一份包含全部改动的新容器。需要注意docker commit 是本地操作不会自动推送镜像仓库。如果只在当前宿主机上 commit换机器就没有这个镜像。我见过不少同事 commit 完就直接去别的机器 docker run结果发现镜像不存在又回来 docker save docker load 导包绕了一大圈。2.3 commit 的代价镜像膨胀、不可复现、安全隐患commit 真正的问题不在“能不能用”而在“该不该作为常规手段”。我在这上面吃过不少亏挑几个典型的说镜像体积膨胀是第一个坑。手工进容器装软件必然会带进一堆临时文件、日志、包管理器缓存、bash 历史。更关键的是前文提到过的 whiteout 机制——你在容器里删掉一个旧文件commit 后的镜像里这个文件仍占用空间。我曾经维护过一个内部工具镜像基础镜像只有 80MB 左右前后几次手工往里装补丁、调配置最终体积涨到了 1.2GB。排查半天发现 /var/log、/var/cache、/root/.bash_history 全被带进去了。第二个坑是不可复现。手工操作没有记录换了个人重新执行同样的步骤最后得到的文件系统状态大概率不一样。镜像出了安全问题你也说不清它是什么时候、基于哪个基础镜像、由谁改了哪些东西构建出来的。这对生产环境来说基本等于失控。第三个坑是安全隐患。手工改容器时很容易顺手把密钥、连接串写进环境变量或配置文件commit 后这些信息会跟着镜像一起被分发。之前有一次同事为了在容器里测试数据库连接把密码写进了容器的 .env 文件然后 commit 并推送镜像仓库差点把内部信息扩散出去。好在那是在内部环境发现得及时。所以我的建议是commit 只用于“抢救现场”不用于“正式交付”。当你需要把改动固化成可长期追溯的镜像时优先选择 Dockerfile 重建也就是下一章要讲的方式。2.4 什么情况下可以接受 commit如果你正在排查线上问题需要把一个“正在坏的状态”完整保留下来供别人分析这时候 commit 是最快的因为现场几百个文件的改动很难用命令逐条还原。操作时可以顺手记录下容器的启动参数和环境变量方便事后把现场信息迁移到 Dockerfile。另外如果只是临时测试一个软件包安装后是否兼容commit 出一个小镜像也无可厚非。但要给镜像打明确的 tag例如 debug-foo-20250101并在清理时及时删除避免留在本机混淆后续使用。3. 让镜像“不可变”用 Dockerfile 重建代替 commit3.1 不可变基础设施的思维转变云原生场景下有一个核心原则基础设施应该尽量不可变。运行环境一旦构建完成不再在运行中修改需要变更时重新构建一个新版本替换旧版本。对应到镜像上就是“镜像只建不复改”构建镜像应该像编译源码一样有明确输入、可审计步骤、可重复结果。如果你接受了这个前提就会明白手工进容器改文件并把改动用 commit 固化属于“给不可变基础设施开了一个可变的口子”。短期方便长期一定是事故源。真正该做的是把所有构建期改动翻译成 Dockerfile 指令。3.2 把容器里的改动翻译成 Dockerfile如果你手头已经有一个被手工改过的容器想迁移成 Dockerfile我建议按这个顺序操作先跑 docker diff 拿到完整的变更文件列表把纯配置类文件比如 nginx.conf、application.properties从容器里提取出来放到项目目录用 COPY/ADD 写进镜像把安装软件包的动作还原成包管理器命令例如 apt-get install 或 apk add把启动命令和参数检查一遍变成镜像的 CMD/ENTRYPOINT运行期产生的日志、缓存、上传文件一律不放进镜像留给数据卷处理。一个简单的例子假设容器里安装了 curl并且替换了 nginx 默认页那么 Dockerfile 可以写成这样FROM nginx:1.25 COPY index.html /usr/share/nginx/html/index.html COPY nginx.conf /etc/nginx/nginx.conf RUN apt-get update \ apt-get install -y curl \ rm -rf /var/lib/apt/lists/*这里有一个细节值得说一下RUN 指令最后为什么要 rm -rf /var/lib/apt/lists/*因为 apt 安装包会把索引缓存写进镜像层不清理的话镜像体积会白白多出几十 MB而且这种体积累加是不可逆的——每一层新增的内容都会保留到最终镜像里。3.3 多阶段构建与版本管理如果你的应用需要编译多阶段构建是控制最终镜像体积的有效手段。它的思路是先用一个包含完整工具链的基础镜像完成编译再把编译产物复制到一个精简的运行镜像里构建过程中的依赖和源码不会进入最终镜像。FROM golang:1.22 AS builder WORKDIR /src COPY . . RUN CGO_ENABLED0 go build -o app . FROM alpine:3.20 RUN apk add --no-cache ca-certificates COPY --frombuilder /src/app /app ENTRYPOINT [/app]构建时的版本管理同样重要。不要只打 latest 标签建议把镜像版本跟代码提交关联起来TAG$(git rev-parse --short HEAD) docker build -t registry.example.com/app:${TAG} . docker push registry.example.com/app:${TAG}这样每个镜像都能对应到一次具体的 git 提交出问题可以让开发直接定位到代码版本。如果你想手动控制构建环境变量可以用 docker build --build-arg 传入版本号或编译参数docker build --build-arg APP_VERSION1.2.3 -t registry.example.com/app:1.2.3 .3.4 从现有容器“抢救”数据而不是“抢救”镜像如果一个容器已经跑得很乱里面有些文件是你需要的又没法通过 Dockerfile 完全还原正确的做法是把文件通过 docker cp 提取出来或者直接 docker export 导出容器文件系统再解包挑选需要的部分放进新镜像的构建上下文里。docker export 导出的是一份完整的容器文件系统 tar 包不同于 docker save 导出的镜像 tar。export 出来的东西不能直接 docker load也无法被 docker run 直接运行但它可以解包浏览是最后的兜底手段。我会在 commit 之前先做一次 export相当于给现场留一份备份而不是把所有希望都押在 commit 成功上。4. 数据卷与挂载持久化数据该放哪4.1 分清“镜像改动”和“运行数据”到了这一章很多读者实际想要的“对容器内更改持久保存”其实指的并不是镜像文件而是容器运行过程中产生的数据。这两件事经常被混在一起讨论但解决方案完全不同镜像里装的程序、依赖、默认配置属于构建期内容应该通过镜像重建来管理容器运行后写入的数据库文件、上传图片、日志、缓存属于运行期数据应该放到数据卷或挂载存储。如果一开始就把运行期数据放在容器可写层不管你怎么 commit容器一删数据就没了即便你 commit 成镜像数据也随着镜像分发得到处都是既难清理又容易出安全问题。正确思路是镜像保持干净和不可变数据委托给卷来管理。4.2 三种持久化方式对比Docker 提供的数据持久化手段主要有三种匿名卷、具名卷和绑定挂载。另外还有一种 tmpfs 挂载只占内存一般用于临时文件不推荐用来持久化。方式数据存放位置容器删除后是否保留适用场景匿名卷宿主机 /var/lib/docker/volumes/ 下随机目录可能残留但难以复用不推荐刻意使用具名卷宿主机 /var/lib/docker/volumes/卷名/保留生产环境持久化数据绑定挂载用户指定的宿主机目录保留本地开发、实时同步配置tmpfs宿主机内存不保留临时缓存、session匿名卷的问题是如果你不指定卷名每次 docker run -v /data 都可能创建一个新的卷时间长了宿主机上会堆满难以辨认的哈希目录非常难维护。所以我会建议至少使用具名卷。具名卷可以通过名字直接被其他容器复用也方便备份和迁移docker volume create appdata docker run -d --name app -v appdata:/app/data some-image绑定挂载的写法则更推荐 --mount 这种完整语法虽然比 -v 啰嗦但语义清晰docker run -d --name app \ --mount typebind,source/home/dev/config,target/etc/app-config \ -p 8080:80 some-image4.3 具名卷的备份与恢复生产环境里用的最多的还是具名卷。它的备份有一套固定套路核心是启动一个临时容器把卷挂载进去用 tar 打包docker run --rm \ -v appdata:/data \ -v $PWD:/backup \ alpine tar czf /backup/appdata.tar.gz -C /data .恢复反向操作即可docker run --rm \ -v appdata:/data \ -v $PWD:/backup \ alpine tar xzf /backup/appdata.tar.gz -C /data这里有个容易被忽视的细节备份容器最好用 --rm 一次性执行避免在宿主机留下大量“数据卷容器”残留。早期 Docker 教程喜欢用一个专门的数据卷容器跑 like -v现在完全不需要了具名卷可以直接跨容器复用。4.4 挂载权限问题最常见的生产事故来源我在生产环境遇到过不少跟权限相关的问题典型表现是容器启动失败或者容器启动成功但写入时报 Permission denied。最常见的原因有两个。一个是绑定挂载的宿主机目录权限不对比如目录属主是 root而容器内进程以 uid 1000 运行自然写不进去。另一个是具名卷第一次挂载时Docker 会按照镜像里对应目录的属主来初始化卷但如果镜像里目录没有显式设置某些镜像默认以 root 运行产生的文件也会属于 root。解决方式一般有三种手动修改宿主机目录属主例如 chown -R 1000:1000 /home/dev/data容器启动时用 --user 指定运行用户如果是 K8s可以利用 fsGroup 统一管理卷的访问权限。我更推荐的做法是在构建镜像时显式声明 USER 指令并在镜像内预创建好卷目录及其属主把权限问题提前到构建期解决而不是每次部署都去手动 chown。5. 云原生视角镜像与数据分离配置也“代码化”5.1 进入 Kubernetes 之后持久化变成了编排层的事当你从单机 Docker 迁移到 Kubernetes会发现“对镜像的更改持久保存”这条思路会进一步演化。在 K8s 里Pod 的 image 只负责提供运行环境它应该是不可变的、从镜像仓库拉取的成熟制品而持久化数据、运行时配置、密钥等分别由 PVC、ConfigMap、Secret 这些 K8s 资源来承载。一次服务升级如果只是修改环境变量或挂载某个配置文件往往不需要重新构建镜像只需要更新 Deployment 里的配置引用K8s 会自动滚动重启 Pod。这就是云原生最核心的思维转变从“改镜像”变成“改编排配置”。5.2 一个完整的 Deployment 示例下面这个例子包含 ConfigMap、PVC 和 Deployment 三部分基本覆盖了“镜像保持不变、数据走卷、配置走 ConfigMap”的典型形态apiVersion: v1 kind: ConfigMap metadata: name: app-config data: app.properties: | log.levelINFO feature.flagtrue --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: app-pvc spec: accessModes: - ReadWriteOnce resources: requests: storage: 10Gi --- apiVersion: apps/v1 kind: Deployment metadata: name: app spec: replicas: 1 selector: matchLabels: app: app template: metadata: labels: app: app spec: containers: - name: app image: registry.example.com/app:abc1234 volumeMounts: - name: appdata mountPath: /app/data - name: config mountPath: /etc/app-config volumes: - name: appdata persistentVolumeClaim: claimName: app-pvc - name: config configMap: name: app-config这里面我特意把 ConfigMap 中的配置以文件方式挂载到 /etc/app-config这样应用可以像读本地文件一样读取配置也会在配置变更时感知到更新。Script 或环境变量注入也是常见做法但文件挂载更容易排错。5.3 从 Docker Compose 到 K8s 的迁移建议如果你的服务还在用 Docker Compose可以先在 Compose 层把配置和数据分离为后续迁移到 K8s 做准备。下面是一个典型的 Compose 文件services: web: image: registry.example.com/app:abc1234 ports: - 8080:80 environment: - LOG_LEVELINFO volumes: - appdata:/app/data configs: - source: app_config target: /etc/app-config/app.properties configs: app_config: file: ./app.properties volumes: appdata:迁移到 K8s 时映射关系大致是Compose 的 environment 对应 Deployment 的 env/envFromCompose 的 configs 对应 ConfigMapCompose 的 volumes 对应 PVC。镜像构建流程保持不变Dockerfile 继续作为唯一镜像来源。这样整体迁移的阻力会小很多。5.4 镜像里不要写运行期可变数据这里再多说一句日志的问题。云原生平台默认通过 stdout 收集日志K8s 里用 kubectl logs 查看容器日志也是建立在应用把日志写到 stdout 的基础上。如果一个应用习惯把日志写到容器内的 /var/log/app.log带来的麻烦是容器可写层被日志占满、kubectl logs 看不到内容、容器重建后日志全丢。合理的做法是让应用把日志输出到 stdout由容器运行时和集群日志系统统一接管如果必须要持久化日志文件那就把日志目录挂载到卷上。这个原则同样适用于缓存数据、临时文件等一切“可再生的运行期数据”。6. 实测中的意外情况与经验清单6.1 “数据又丢了”的排查链路即使理解了上面所有机制实际操作中还是会出现各种意外。我总结了一套排查思路遇到问题可以按这个顺序查第一步确认容器是不是真的被删了。用 docker ps -a 查看所有容器包含已退出状态的对象不要只看 docker ps。第二步确认挂载是否生效。docker inspect 看 Mounts 字段确认卷名、源路径、容器内路径三者的对应是否正确。有时候你以为挂上了其实因为路径写错挂载点根本不存在。第三步确认不是匿名卷“看起来丢了”。没那么少见——之前用 docker run -v /app/data 创建了一个匿名卷后来再启动时没带这个参数数据当然不再出现。改用具名卷后这种问题基本绝迹。第四步确认挂载目录没有遮盖容器内原有内容。比如镜像里 /app/data 有初始化脚本准备的数据你把宿主机空目录 bind mount 上去容器看到的 /app/data 就是空目录初始化内容被遮蔽了。这种问题需要先检查镜像目录的内容再决定是否需要把初始化文件复制到宿主机目录。第五步检查权限问题。容器启动失败了先看日志是不是 Permission denied如果是回到上一章讲的权限处理方案。6.2 我的日常操作准则在大量踩坑之后我现在维护镜像和容器数据的准则基本固定下来了镜像构建只走 Dockerfile任何需要进容器改的操作先问自己能不能写进 Dockerfile。能就重新构建发布不能考虑用卷。运行期写入的数据一律挂到具名卷日志全部输出到 stdout不写本地文件。生产容器用非 root 运行镜像结构里提前声明 USER 和卷目录权限。镜像 tag 和 git 提交关联出问题能直接回溯到代码版本而不是在一堆 latest 里猜。本地调试用手工 commit 可以但必须打调试专用 tag用完删除绝不推送正式仓库。这套准则看起来很基础但能挡住大部分“镜像越来越大”“数据神秘消失”“现场无法复现”之类的常见问题。6.3 最后分享一个小技巧如果你实在要 docker commit 一个容器我建议先执行一次 docker export 把整个文件系统完整备份成 tar 包再执行 commit。这样即使 commit 出来的镜像有问题你还有一份原始文件系统可以挖掘。commit 和 export 的区别在于commit 生成的是可以被 docker run 直接使用的镜像export 生成的是文件系统的归档包后者更适合做数据抢救。如果你在容器里改了配置并希望以后每次启动都生效还有一个思路值得试试不要进入容器改文件而是把配置放到宿主机目录用 bind mount 挂进去。这样即使容器删了配置仍然在宿主机上而且你还能用 git 之类的工具对这份配置做版本管理。从某种意义上说这也是一种“对 Docker 镜像的更改持久保存”——只是保存的位置从镜像层转移到了更可控的外部存储。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑