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开始,每一条指令都对应一层。图中的第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 - 指令顺序是不是先装依赖、后复制代码
这三条做到,你的镜像构建就算上了正轨。接下来就可以安心往下走了。