镜像与容器的关系归纳总结

镜像和容器是什么关系?说白了,镜像就是「静态的模板」,容器就是「跑起来的实例」。嗯,这个类比几乎所有教程都会说,但真正在项目里用过的人都知道——这里面坑不少。

核心关系:镜像 ≈ 类,容器 ≈ 对象

如果你写过面向对象的代码,这个类比最贴切。镜像定义了环境的所有「属性」和「初始状态」,容器则是根据这个模板「new」出来的一个运行实体。我习惯这么说:镜像是冰箱里的速冻水饺,容器是下锅煮好的那一盘。

  • 镜像不可变:构建完成后内容只读,除非你重新 build。
  • 容器可变:启动后会在镜像层上加一层「可写层」,所有修改都在这一层里。
  • 镜像可复用:同一镜像可以同时启动 N 个容器,互不干扰。
  • 容器可销毁:删除容器不会影响镜像本身。

我的习惯:在项目初期,我常把镜像当作「环境快照」,容器当作「调试沙盒」。每次修改依赖就重新 build 镜像,而不是在容器里手动 apt install——否则没人能复现你的环境。

生命周期对比

维度镜像容器
创建方式docker build / docker pulldocker create / docker run
存在形态静止的层叠文件系统运行中的进程 + 读写层
数据持久性永远不丢(除非手动删除)删除容器即丢弃,除非挂载卷
能否被修改不能(只读)可以(在容器内任意写)
分发方式推送到 Registry不能直接分发,需导出或 commit 成镜像

我记得有一次线上排查,运维说「容器里环境不对」,我让他 docker diff 一下看看改了哪些文件——结果发现有人在容器里手动改了 php.ini。后来我强制要求:所有配置变化必须通过新镜像发布,容器只允许跑启动脚本。

镜像层与容器层的「写时复制」

为什么容器启动那么快?因为 Docker 用了 UnionFS —— 镜像层是只读的,容器启动时在上面叠加一个「薄薄」的读写层。你修改文件时,Docker 会把文件从底层复制到读写层,然后修改副本。这叫做「写时复制(Copy-on-Write)」。

# 查看镜像的层
docker history my-app:latest

IMAGE          CREATED       CREATED BY                                      SIZE
abc123         2 hours ago   CMD ["node","app.js"]                          0B
def456         2 hours ago   COPY package*.json ./                          2.1MB
ghi789         3 hours ago   RUN npm install                                150MB
jkl012         4 hours ago   FROM node:18-alpine                            120MB

# 查看容器的读写层变化
docker diff my-container
C /app          # 修改过的目录
A /app/logs     # 新增的文件
D /tmp/old      # 删除的文件

我曾经踩过的一个坑:在容器里生成了几十 GB 的日志数据,结果容器删除后,这些日志全部被丢弃,但是……因为日志写在容器层,容器删除后空间并没有立刻归还给宿主机,而是被保留在 orphan layers 里。手动清理 docker system prune 才释放。所以现在所有持久数据我都强制用 volume,绝不写容器层。

镜像到容器的三种转换方式

不只是 docker run 这一条路。实际工作中我还遇到过下面几种需求:

  1. 标准启动: docker run --name web -d nginx:latest —— 最常用,镜像直接变为运行容器。
  2. 创建但不启动: docker create --name debug ubuntu:22.04 —— 我习惯用它做「容器预配置」,随后用 docker start 启动。
  3. 从容器导出再导入为镜像:
    # 导出容器文件系统
    docker export my-container > backup.tar
    
    # 导入为镜像
    cat backup.tar | docker import - my-app:backup
    这个操作会丢失所有镜像层历史,得到一个「扁平」镜像。我只在需要迁移完整文件系统且不在乎元数据时用。

你想想看,为什么要有这么多方式?因为实际场景千奇百怪。比如我接手过一个老项目,它用 docker commit 在容器里修改后再提交成新镜像——这种做法我极其反对,因为层会膨胀,且无法回滚。但有时候紧急修复线上 bug,来不及走 CI 流程……

避坑指南:除了特殊情况,永远不要用 docker commit 创建新镜像。它破坏了「不可变基础设施」的原则。如果确实需要容器的状态,先用 docker export 导出文件系统,再通过 Dockerfile 重新构建。

容器能否脱离镜像存在?

严格来说,不能。但有个特例:docker import 从 tarball 导入的「镜像」其实没有父镜像,它自己就是根文件系统。这种「根文件系统镜像」通常用于构建 scratch 基础镜像或迁移物理机环境。

我记得做一次 ARM 交叉编译环境迁移时,我直接把开发板的根文件系统打包成 tar,然后 docker import 成一个镜像——虽然没有任何历史层,但容器跑起来了,里面的工具链和库都完全一致。嗯,这种方式虽然不「优雅」,但在应急场景下很实用。

SVG:镜像与容器的生命周期关系图

镜像与容器的生命周期关系 镜像 (Image) docker build Dockerfile Registry docker pull docker run / create 容器 (Container) 运行中 停止 / 已删除 stop docker commit rm → 不可恢复 重要概念 镜像:静态只读模板,不可变。 容器:动态运行实例,有读写层。 容器层随容器删除而丢失, 但挂载的 volume 不会丢失。

实用的排查命令集合

最后再分享几个我经常用来「盘」镜像和容器关系的命令,遇到问题先跑一圈:

# 查看镜像的被依赖关系(有没有容器在用它?)
docker image inspect my-app:latest | jq '.[].Containers'

# 查看某个容器是基于哪个镜像
docker inspect my-container --format '{{.Config.Image}}'

# 查看容器层相对于镜像层的变更
docker diff my-container

# 导出容器文件系统,排查环境差异
docker export my-container > debug.tar
tar tf debug.tar | head -20

一个小技巧:当你怀疑容器里的文件被修改过,但不确定改了哪些,直接用 docker diff。我经常用它来判断「这个容器还能不能 commit 回测试镜像」,如果 diff 出来的文件太多(比如超过100个),我会重新构建一个新镜像,不再费力保留容器的层。

镜像和容器的关系,说到底就这两句话:镜像提供「确定性」,容器提供「灵活性」。保持镜像的不可变性,把容器当作一次性的执行单元——做到这一点,你的 Docker 环境一致性就成功了一大半。