Docker diff 详解:看清容器里到底改了什么
如果你和我一样经常需要排查容器异常、分析镜像体积、或者做安全审计,那你一定有过这种经历:容器跑着跑着突然多了一个不明文件,或者某个目录被莫名修改,你心里只有一个问题——容器里到底改了什么?笨办法是docker exec进去逐个ls -la对比,但找半天也不知道哪些是启动时自动生成的,哪些是程序运行时写进去的。这种情况,docker diff就是你的第一排查工具。
docker diff算得上 Docker 命令家族里最不起眼但最实用的成员之一,它用来显示容器在启动之后,它的文件系统相对镜像层发生了哪些增减、修改。简单说,它能告诉你这个容器从创建到运行期间,对文件系统的每次“动手脚”。它能帮你快速定位异常文件、清理镜像冗余、回溯容器状态变更,特别适合做运维排查、开发调试和镜像瘦身的场景。这篇文章我会从原理讲到实操,再到坑位排雷,把docker diff从头到尾给你捋一遍。
1. 先搞清楚 docker diff 在看什么
1.1 容器文件的层级结构与“可写层”
要理解docker diff,就要先理解 Docker 镜像和容器的存储机制。Docker 镜像是由多层只读层(read-only layer)叠加而成的,每一层代表一组文件系统的变更。当我们启动一个容器时,Docker 不会修改这些只读层,而是在镜像层之上添加一个可写层(writable layer),也叫容器层。所有对文件系统的修改都发生在这一层里。
你可以把容器层想象成一张透明胶片,镜像层是底层的图案。你对容器做的任何改动,都等于在这张透明胶片上画画,但是不会破坏底下的图案。docker diff干的事情,就是把你画的那些线条精确列出来——哪些是新增的,哪些是修改的,哪些是删除的。
这个机制决定了容器的生命周期特性:容器可以启动、停止、删除,但是镜像层永远保持不变。如果你想知道一个容器从镜像启动之后都改了哪些文件,去看那个可写层的变更记录就一目了然。
1.2 docker diff 的输出格式与状态码
在没有做任何操作时,全新容器刚启动时,它的可写层会记录一些初始化产生的文件。运行docker diff可以看到输出,每一行由两部分组成:状态码和文件路径。
状态码主要有三种:
| 状态编码 | 含义 | 说明 |
|---|---|---|
A | Added | 新增的文件或目录,只存在于可写层中 |
C | Changed | 文件或目录发生变更,比如内容被修改、属性改变(权限、所有权等) |
D | Deleted | 从镜像层中删除的文件或目录,可写层中记录了删除标记 |
比如:
C /etc/nginx/nginx.conf A /var/log/nginx/access.log D /etc/nginx/conf.d/default.conf这里的路径是相对于容器根目录的路径,前面带不带斜杠都行。你可能会想,这些路径命令输出得是不是绝对路径?是的,它是从/开始描述的路径,但注意它输出的是容器文件系统里的路径,而不是宿主机的路径。
有一点需要特别说明:当你修改一个文件时,Docker 是把整个文件复制到可写层,再在可写层里做修改,所以C状态表示的是这个文件在可写层中被整体覆盖过,而不是只记录差异的部分。这也是为什么docker diff对于大文件来说,可能显示C但实际变更只有很小一部分。
2. 什么场景下必须用 docker diff
2.1 排查容器异常改动:生产环境救火
最常见的场景是,容器运行一段时间之后,服务突然报错,你怀疑是某个配置文件被改动了,或者某个目录下多了不该有的文件。比如 PHP 容器被挂马,第一反应就是看容器里哪些文件被新增或者修改了。docker diff能直接帮你在海量路径中找到可疑项。
拿一个我真实踩过的例子说吧,之前我的一个 Nginx 容器出现过首页被篡改的情况。正常情况下,首页文件index.html是从镜像里打包进去的,容器运行后应该保持不变。结果我docker diff一看,输出里有一个A /usr/share/nginx/html/backdoor.php,瞬间就定位到了问题路径。不用再一层层目录翻,比自己find快多了。
再比如 MySQL 容器中莫名多出大量.ibd文件,用docker diff可以对比容器初始状态和当前状态,找出哪些是新生成的文件,再结合日志判断是否业务异常写入。
2.2 制作精简镜像:知道哪些文件是多余的
如果你想基于现有容器做一个“干净”的镜像,docker diff可以帮助你判断容器在运行过程中产生的文件哪些值得保留、哪些应该清理。比如一个 Java 应用容器,你跑了几天后想把它重新docker commit成镜像,但你又不想把日志文件、临时文件、缓存文件都打进去。这时候你先docker diff看一下,把/var/log、/tmp下那些明显是运行垃圾的路径梳理出来,再在 commit 前手动删掉,或者写一个清理脚本,让最终镜像体积小很多。
我有一个习惯,每次在容器里手动改完文件并成功验证后,我会先跑docker diff把这个容器的变更内容记下来,再据此写 Dockerfile。也就是说,docker diff在我这里是“从手工操作到自动化部署”的翻译器。你把手工操作步骤全部 grep 出来,看看哪些文件改了,那些文件创建了,再对应写成 RUN 命令,比自己凭记忆写 Dockerfile 靠谱得多。
2.3 安全审计与合规检查
容器内的文件变更往往意味着风险。安全扫描、合规审计时,docker diff可以帮你看清运行时容器与基础镜像之间的偏差。比如某些容器被植入挖矿程序,往往会在/var/spool/cron/crontabs、/tmp、/usr/lib等目录添加可疑脚本。通过定期对容器做docker diff快照,可以快速比较出历史变更。
一个完整的安全审计通常要结合以下手段:
docker diff找出可疑变更路径。docker inspect查看容器启动参数、挂载信息。docker logs回溯异常进程日志。- 进入容器内对这些路径进行文本分析或哈希比对。
docker diff只能指出文件系统的变化,但文件内容是什么、是否恶意,还需要你进一步分析。不过它已经帮你划定了范围,极大缩小了排查面积。
3. docker diff 实操:从入门到进阶
3.1 基本操作:怎么查看一个容器的变更
首先用docker ps找到你要分析的容器名称或 ID,然后执行:
docker diff <容器名或ID>比如:
docker diff my_nginx输出可能像这样:
C /etc C /etc/nginx C /etc/nginx/nginx.conf A /run A /run/nginx.pid C /var C /var/cache C /var/cache/nginx A /var/cache/nginx/client_temp A /var/cache/nginx/fastcgi_temp ...这个列表会把从容器创建开始,所有在可写层中发生的变更都展示出来。如果你初期没操作太多,列表会短一些。如果容器运行了很久,列表可能非常长,这时候你要善用grep过滤:
docker diff my_nginx | grep -E "^A /etc|^C /etc"这样可以只看/etc目录下新增和修改的文件。
有一点需要说明:docker diff只对运行中的容器有效,但也适用于已停止的容器吗?答案是可以。只要这个容器没有被删除,即使它当前是停止状态,你依然可以执行docker diff,因为可写层的数据还保存在磁盘上。
3.2 结合 docker inspect 看准确时间线
有时候容器被反复启动过,你不知道这些变更究竟发生在哪一次启动过程中。没关系,把docker diff和docker inspect的信息结合起来,能推断出变更的先后逻辑。
docker inspect可以拿到容器的启动时间、结束时间、挂载卷、环境变量等。例如:
docker inspect -f '{{.State.StartedAt}}' my_nginx你可以记录下每次启动的时间点,再对照容器内文件的时间戳,判断变更发生在哪个阶段。不过这里要提醒一句:docker diff是累积的,它显示的是容器可写层中的所有变更,并不会区分是在第几次启动时改的。所以如果你需要定位每个时间点的具体变更,那应该在关键操作前后分别拍快照:
docker diff my_nginx > /tmp/diff_before.txt # 执行某个操作 docker diff my_nginx > /tmp/diff_after.txt diff /tmp/diff_before.txt /tmp/diff_after.txt通过对比前后 diff 的输出,你就能看到这一段时间内新增的变更了。这是我在生产环境里最常用的方法——它比单次docker diff更加动态,也更适合排查特定时段的异常行为。
3.3 看 docker diff 如何反映文件创建还是内容修改
你可能已经注意到,docker diff输出的A、C、D分别代表新增、修改、删除。但需要注意,C也可能是属性变更,比如文件权限发生了变化。这在我排查权限问题时非常有用。
比如有次我的 Redis 容器报错,说无法写 AOF 文件。我docker diff一看,发现/data目录状态为C,再进去看权限,原来容器启动后有人手动执行过chown redis:redis /data,导致目录属主变了,Redis 进程却没权限写入。这种属性变更如果不用docker diff去对初始状态,单纯看当前目录权限是看不出问题的。所以我把这个场景列为必查项:当你怀疑容器内文件权限被改动过时,docker diff的C状态就是你判断的重要线索。
4. 容器运行期间的文件变更分析
4.1 启动新容器,快速发现初始化生成物
如果你手里有一张官方镜像,想看看这个镜像启动之后,运行时会初始化哪些文件,很简单:用这个镜像启动一个新容器,不要做任何额外操作,然后立刻执行docker diff。这个命令列出的所有项目,就是这个镜像运行时自动生成的文件列表。
比如启动一个postgres:13容器:
docker run -d --name tmp_pg postgres:13 docker diff tmp_pg你会看到类似这些内容:
A /var/lib/postgresql/data C /var/lib/postgresql C /etc/postgresql A /var/lib/postgresql/data/global ...这些信息对于你做基础设施自动化非常有价值。你可以在初始化容器后拿到第一手资料,知道哪些目录是数据库自己创建的,哪些文件是默认生成的,然后把这些路径写进你自己定制的镜像里,或者用于初始化脚本的判断逻辑。
4.2 从 diff 结果追踪非法文件落地
这个场景前面稍微提过,我再展开细讲。很多时候容器安全事件的第一现场并非网卡流量,而是文件系统突然多了不该存在的东西。比如挖矿病毒、Webshell、后门用户这类东西,往往就是几个文件的事。
当你知道容器 ID 后,直接:
docker diff <container_id> | grep -E "^A"把所有新增文件列出来,然后重点关注目录内文件。如果出现可执行文件,尤其是带x权限的脚本或二进制,就要警惕了。下面是几个我建议重点排查的路径:
| 路径 | 安全风险 |
|---|---|
/tmp | 临时文件被恶意写入,常被用来放置挖矿程序 |
/var/spool/cron | 持久化任务被植入恶意脚本 |
/usr/local/bin | 二进制被替换或新增后门工具 |
/etc/ld.so.preload | 动态链接库劫持、Rootkit 痕迹 |
/www或 web 根目录 | Webshell 上传 |
在容器中执行docker diff之前,建议先确认容器的 PID 命名空间是否隔离正常。如果你在宿主机上直接操作,docker diff使用的是 Docker 守护进程的信息,不会触发容器内进程的干扰,所以相对安全。
另一点提醒:docker diff默认是从 Docker 守护进程读取容器的文件系统元数据,性能开销并不大,但在生产环境的大规模容器集群里,如果每个节点上都有上百个容器,每次都全量 diff 仍然会产生一定开销。我的经验是不要在高峰期做全量 diff,而是针对嫌疑容器做定向 diff。
4.3 用 docker diff 辅助 docker commit
有时候我们确实需要把容器当前状态保存为一个新镜像,比如在一台临时机器上装好环境,然后 commit 下来迁移到其他机器。这时候如果直接:
docker commit mycontainer myimage:latest会把容器可写层里所有内容都打进去,包括日志缓存、临时文件、安装包等,镜像体积会膨胀得很难看。正确做法是:
- 先清理容器内的无用文件:删除
/tmp下的临时文件、清除 apt/yum 缓存、删除日志。 - 再用
docker diff检查剩余修改,看看哪些路径仍然异常。 - 确认无关键多余文件后,再执行 commit。
我举个简单例子,假设容器内安装了 Python 并写了几个脚本,此时 diff 输出类似于:
A /app A /usr/local/bin/python3.11 C /etc ...如果你看到/usr/local/lib/python3.11/site-packages下面有大量__pycache__目录,那说明你在构建镜像时没有排除缓存文件。你可以在 commit 前执行:
docker exec mycontainer find / -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null再 diff 确认一下,然后 commit,镜像体积能明显降下来。
docker diff在这里的定位就像“文件系统变更清单”。没有它,你可能完全不知道自己 commit 出来的镜像里有没有混进奇怪的临时文件——等到镜像被推到仓库,再被人 pull 下来跑出问题,那就得花更多时间排查了。
5. docker diff 常见坑和易混淆点
5.1 挂载卷为什么看不到?这不是 bug
很多人在容器里创建了几十个文件,但跑docker diff一点反应都没有,怎么想都觉得不对。这种情况十有八九是遇到了绑定挂载卷或数据卷。
我举个例子:
docker run -d --name test -v /tmp/host_data:/opt/app_data nginx:alpine docker exec test touch /opt/app_data/test.txt docker diff test如果你在diff输出里找不到/opt/app_data/test.txt,不要奇怪。因为/opt/app_data是挂载卷,它不属于容器的可写层,而是直接映射到宿主机目录。docker diff只能看到容器可写层的变更,挂载卷的内容变更根本不会出现在 diff 结果里。
我在刚开始用 Docker 时就被这个坑困了很久,以为命令出问题了。后来才明白,如果你想审计挂载卷里的文件变更,不能用docker diff,得直接查宿主机目录,或者通过docker exec进去用find、stat等工具手动排查。这是很多新手甚至部分老手都会踩的坑,我特意拿出来提醒大家。
另外,匿名卷(anonymous volume)也类似。容器删除时如果带了-v,匿名卷里的内容其实还会独立保留,但docker diff同样无法追踪。所以如果你需要了解挂载卷里有没有被改动,就得设计别的方案了。
5.2 docker diff 会不会看出所有修改?哪些被排除
docker diff基于 overlay2 存储驱动实现,它反映的是可写层相对 нижнего 层的差异。但并不是所有文件都会出现在 diff 中,有几类情况需要注意:
- 挂载卷中的文件变更(上一节已说明)。
- 由 tmpfs 挂载的文件路径(比如
/run在部分镜像中是以 tmpfs 方式挂载的),重启容器后会被清空。 - 使用
docker exec修改文件时,如果文件本身存储在挂载卷内,同样不会被记录。
此外,docker diff只能展示“存在差异”的路径,而不会记录文件内容的完整变化细节。也就是说它告诉你改了哪些文件,但不会告诉你怎么改的。要查看具体内容,你需要另想办法,比如进入容器看文件内容,或者对比宿主机上的副本。
还有一个容易误解的地方:docker diff的路径显示的是容器内的绝对路径,不是宿主机路径。如果你在宿主机上跑docker diff,它输出的路径不会加上宿主机/var/lib/docker/overlay2/xxx/diff前缀,所以你无法直接拿着这个路径去宿主机上查看文件。如果你需要精确定位容器可写层在宿主机上的真实位置,要用:
docker inspect -f '{{.GraphDriver.Data.UpperDir}}' my_container然后到这个目录去查看对应路径的文件。这条信息在做容器逃逸、取证分析时极其重要,我也是后来才琢磨明白的。
5.3 diff 输出太多时,如何快速过滤出高价值信息
当容器运行了很久,docker diff结果可能成千上百行。面对一大屏输出,人很容易看花眼。我通常的做法是分几步过滤。
第一步,先看所有删除了什么:
docker diff my_container | grep "^D"删除文件通常意味着可能有用户删了关键配置,也有可能是恶意程序为了隐藏痕迹做清理。
第二步,看新增的可执行文件:
docker diff my_container | grep "^A" | xargs -I {} docker exec my_container test -x {} && echo {} executable这个方案能大概筛出新增的可执行文件。注意,docker exec执行时可能因为权限不足而提示,需要你结合实际情况处理。
第三步,看修改的配置类文件:
docker diff my_container | grep "^C" | grep -E "\.(conf|ini|xml|yaml|yml|toml|json)$"配置文件被改,往往是导致软件行为异常的主要原因。
如果容器里文件特别多,建议先把 diff 导出到文件里:
docker diff my_container > /tmp/diff_output.txt然后在宿主机上对/tmp/diff_output.txt做各种 grep、awk、sort。这比在终端里直接刷屏高效得多。
6. 进阶用法:用 docker diff 构建异常检测脚本
当你熟悉了docker diff的用法之后,可以把它固化成一个简单的检测脚本,对集群里的容器做定期巡检。脚本思路很简单:
- 获取容器列表。
- 分别记录每个容器的
docker diff结果。 - 与上一次的运行结果对比,发现新增或异常变更就告警。
用 Shell 或者 Python 都能实现。这里给一个极简的 Shell 示例:
#!/bin/bash container=$1 baseline_file="/tmp/diff_baseline_${container}.txt" current_file="/tmp/diff_current_${container}.txt" docker diff "$container" > "$current_file" if [ ! -f "$baseline_file" ]; then cp "$current_file" "$baseline_file" echo "基线已建立" else diff "$baseline_file" "$current_file" fi这个脚本的局限是不能自动判断哪些变更属于正常业务,哪些属于异常。更好的方案是在脚本里维护一个白名单路径库,比如容器的日志目录、缓存目录、PID 文件路径等允许变化,其他路径一旦出现新增或修改则告警。
我自己在好几套生产环境里都用了类似的思路,配合 cron 每天执行一次,再把结果推送到监控系统,确实能提前发现一些隐患。比如某个凌晨,脚本发现一个数据库容器多了一个从未见过的/tmp/mysql_backup.sh,后面确认是同事手动上传的备份脚本,虚惊一场,但至少说明脚本的感知能力是有效的。
这里再强调一下,docker diff不是实时的,它反映的是容器启动至今的全部变更。所以你要用它做异常检测,必须要有基线数据。没有基线,任何 diff 输出都是“一次性快照”,很难判定异常。这也是很多新人用不好docker diff的核心原因——他们总是等到异常发生才去 diff,而不是在容器健康时就保存一份基线。
6.1 结合 docker events 做实时监控
如果你需要更实时的文件变更监控,docker diff做不到。docker diff是上下文回溯工具,不是事件流工具。这个时候可以借助docker events:
docker events --filter 'container=my_container'但它只能看到容器生命周期事件,不会具体到文件级别。真正文件级别的实时监控只能进入容器内使用 inotify,或通过挂载目录在宿主机上用auditd监控。docker diff的定位更像是“事后取证”,它和实时监控并不冲突,反而可以互补。我一般会在容器启动后立刻保存一次docker diff基线,然后在出现可疑事件时用最新 diff 和基线做对比,能快速定位变更。
6.2 用 docker diff 写出一份容器“操作报告”
还有一个巧妙的应用场景:把docker diff的结果作为容器操作报告。比如你帮同事排查一个问题,容器里做了很多调整,最后你希望能告诉同事“我改过哪些文件”。你只需在操作前保存一份 diff 快照,操作后再保存一份,最后对比两份差异,生成报告。这比凭记忆手写一份“我改了啥”要准确得多。
我经常在帮客户做容器故障诊断时用这个套路。先跑docker diff拿基线,修复问题后对比输出,然后直接把 diff 结果发过去,客户一目了然。这样既专业,又不会漏掉任何关键路径。
7. 日常使用心得体会
写了这么多,最后还是想分享几个我实际摸爬滚打总结出来的小经验。
第一条:养成容器启动即保存基线的习惯。每次创建容器,跑完健康检查之后,顺手执行:
docker diff $container > /tmp/${container}_baseline.txt这个动作不影响任何业务,却能让你以后排查问题时节省两个小时。别指望 Docker 自动帮你保存“初始状态”,容器删了就没了。就算容器还在,你没存基线,就无法区分哪些改动是最初就有的、哪些是后来发生的。
第二条:不要迷信 docker diff 输出没有变化。前面说过挂载卷是盲区,但还有更隐蔽的情况。比如容器内文件被修改后又恢复原样,docker diff仍可能显示C,也可能显示旧路径的变更已被覆盖。具体表现取决于 overlay 的实现。如果你需要确保某一时刻的 diff 反映真实状态,最稳妥的办法是重启容器后再 diff 一次。因为重启后,部分临时文件会被重新初始化,可写层会被清理或重置,diff 结果会瘦身。
第三条:在写 Dockerfile 时,可以把 docker diff 当作“参考答案”。很多人在编写 Dockerfile 时,不确定到底该把哪些目录做成 VOLUME,哪些文件属于运行时生成。你在本地手工搭建一遍环境,运行一次容器,保存 diff,对照 diff 结果来设计 VOLUME 和持久化策略,就能避免把运行时数据误删或误缓存。
我见过太多团队,随便把整个/var/lib/mysql或者/etc/nginx挂出来,结果要么权限错乱,要么丢失默认配置。如果你先跑一次docker diff,看清楚哪些属于镜像自带文件、哪些是运行初始化生成的,再决定挂载层级,很多问题不至于发生。
docker diff就像容器的“变动账本”,它不会替你做决策,但把所有变更一笔一笔记得清清楚楚。你越熟悉它,越能在关键时刻把它用到极致。这一篇文章讲到的用法和坑,都是我一点一点在实际生产环境里磨出来的,希望能帮你少走几条弯路。