Docker镜像基础:构建与分层

镜像这东西,说白了就是一个只读的模板。你跑一个容器,就是从这个模板里启动的。我今天要跟你聊的就是,这个模板怎么造出来,以及它内部到底是怎么叠起来的。

很多初学者觉得Dockerfile就是写几行命令,build一下就完事了。嗯,确实能跑,但你不理解分层机制,后面镜像越做越臃肿,构建时间越来越长,到时候你都不知道问题出在哪。

我个人的习惯是,先搞明白分层原理,再动手写Dockerfile。思路清楚了,后面才不会被坑。

镜像的构建:Dockerfile 是蓝图

先看看最基本的构建流程。你写一个Dockerfile,然后执行 docker build,Docker就会按照里面的指令一步步造出镜像。

下面是一个最简单的例子:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3
COPY app.py /app/
CMD ["python3", "/app/app.py"]

执行构建命令:

docker build -t my-python-app:1.0 .

注意,最后那个点 . 代表构建上下文。Docker会把当前目录全部打包发给守护进程。如果你目录里有一堆临时文件、日志、node_modules,那构建速度会受影响。

小建议: 在每个项目里放个 .dockerignore 文件,把不需要的文件过滤掉。比如:
node_modules
.git
*.log
__pycache__

分层机制:镜像为什么能复用

镜像不是一整块大文件,而是由若干层(Layer)堆起来的。每一条Dockerfile指令,只要会产生文件系统变更,就会新建一个层。

拿上面的例子来说,你会得到三层:

层序号 来源指令 内容说明
第0层 FROM ubuntu:22.04 基础系统层,包含Ubuntu文件和包管理器
第1层 RUN apt-get install python3 安装Python3后新增的文件
第2层 COPY app.py /app/ 你的应用代码

每一层只记录与上一层的差异。这样做有两个好处:

  • 存储节省: 如果你从同一个Ubuntu基础镜像出发,100个容器都共享那层,不需要各存一份。
  • 构建加速: 层有缓存。如果你修改了 app.py,前面安装Python的那一层缓存还能用,不需要重装。
关键点: 层是不可变的,一旦构建完成就不会再被改变。新版本的镜像只是基于旧层再加一层。

为什么会这样设计? 因为层是内容寻址的。每一层有一个唯一的SHA256哈希值,如果你在另一台机器上构建完全相同的指令序列,会得到一模一样的哈希值。这就是可重复性的根基。

Dockerfile 核心指令与注意事项

下面列出常用的几条指令,重点说我还记得的踩坑经历:

指令 用途 是否产生新层 我的避坑经验
FROM 指定基础镜像 是(基线层) 选带-slim的镜像,别用:latest,锁定版本号比如:22.04
RUN 执行命令 是 把多条命令用&&连起来,少生成几层
COPY 从构建上下文复制文件 是 只复制需要的,不要一次性COPY . /
CMD 容器启动时的默认命令 否 不要和ENTRYPOINT搞混,CMD可以被覆盖
ENV 设置环境变量 否(只记录元数据) 敏感信息别写死,用--build-arg传

我曾经遇到过一个项目,同事把RUN拆成三行:

RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y python3

结果每次apt-get update那层缓存失效,后面两层全要重跑……构建时间从30秒拖到3分钟。

正确的做法是:

RUN apt-get update && \
    apt-get install -y curl python3 && \
    rm -rf /var/lib/apt/lists/*

最后那条rm是清理包缓存,能让这层体积小不少。

注意: 清理动作必须跟安装语句放在同一个RUN里。如果你分成两个RUN,第一层留下了索引文件,第二层虽然删掉了,但第一层的体积仍然保留在镜像里。

构建上下文与 .dockerignore

刚才提到构建上下文是当前目录,但实际项目中往往目录很大。我建议你养成习惯,项目根目录下加一个.dockerignore:

# 例子:过滤掉不需要的文件
.git/
*.md
test/
docs/
*.log
__pycache__/
.venv/

这样做的好处有两个:

  • 构建上下文变小,传输给Docker守护进程的速度快
  • 避免不小心把密码、密钥文件打进去

镜像分层结构图

下面这张图展示了镜像的分层结构和构建流程,你看着会更直观:

镜像分层结构与构建流程 Dockerfile 指令序列 docker build 引擎 第2层:COPY app.py 第1层:RUN pip install 第0层:FROM ubuntu:22.04 最终镜像 只读 + 分层可复用 每层只记录与上层的差异,层层堆叠 缓存命中加速构建 指令顺序影响缓存效率

你想想看,从Dockerfile开始,每一条指令都对应一层。图中的第0层是基础系统,第1层装依赖,第2层部署代码。最终镜像就是这三层的堆叠结果。

构建缓存与层的复用

Docker构建时,默认会检查你当前要执行的指令是否跟之前某层完全一样。如果一样,就直接复用缓存,跳过执行。

这里有一个很实用的技巧:把不常变的指令放前面。比如:

# 基础镜像和系统依赖——几乎不变
FROM node:18-slim
RUN apt-get update && apt-get install -y curl

# 项目依赖——改 package.json 时才变
COPY package*.json ./
RUN npm install

# 应用代码——经常修改
COPY src/ ./src/
CMD ["node", "src/index.js"]

这样做的好处是:你改一行源代码,前面安装系统包和node_modules的层都能命中缓存,构建时间从3分钟降到10秒。

注意: 如果你把 COPY . . 写在前面,哪怕只改了一个文件,后面的 RUN npm install 也会因为文件变化而失效缓存。这就是为什么要把“不常变”的内容放在前面。

镜像的可重复性

我们做质控流程,最怕的是“在我机器上能跑,换台机器就崩”。镜像层的不变性保证了:只要Dockerfile不变,基础镜像版本锁定,构建出来的镜像哈希值理论上是一致的。

但这里有坑。比如 apt-get update 每次拿到的包版本可能不同,基础镜像的 :latest 标签实际指向的镜像也会变化。

所以,我的做法是:

  • 基础镜像锁定摘要: 用 FROM ubuntu:22.04@sha256:xxxxxxxxx
  • 固定系统包版本: 如果做严格质控,把安装的包版本号写死
  • 利用构建缓存策略: 用 --cache-from 指定远端缓存,保证CI构建和历史构建一致
docker build --cache-from my-python-app:build-cache -t my-python-app:1.0 .

这样一来,换了机器、换了CI环境,只要依赖版本固定,构建结果几乎不会走样。

小结与实践建议

到这你应该明白了:镜像构建的核心是指令序列和分层复用。我建议你现在就去看看自己项目里的Dockerfile,检查三点:

  • 基础镜像有没有锁定版本
  • 有没有 .dockerignore
  • 指令顺序是不是先装依赖、后复制代码

这三条做到,你的镜像构建就算上了正轨。接下来就可以安心往下走了。