4. Dockerfile 基础:FROM、RUN、COPY、CMD 指令详解

说实话,Dockerfile 就是镜像的“配方”。你写什么,镜像就长什么样。我早期带团队的时候,发现很多问题根源不是业务复杂,而是 Dockerfile 写得太随意。今天咱们把四个最基础的指令彻底聊透。

4.1 FROM:一切的起点

FROM 是 Dockerfile 的第一行。没有它,后面的指令都无处安放。说白了,就是指定你的镜像从哪里长出来。

FROM ubuntu:22.04
FROM python:3.11-slim
FROM alpine:3.18

我个人习惯是——能用 alpine 就选 alpine。为什么呢?体积小,攻击面窄。我在项目中遇到过一个案例:基础镜像从 ubuntu 换到 alpine,最终镜像从 1.2GB 直接砍到 350MB。你想想看,这部署效率能差多少。

核心原则:基础镜像越小,安全风险越低,分发速度越快。

这里有个坑:alpine 用的是 musl libc,不是 glibc。如果你编译的应用依赖 glibc 特性,别硬上 alpine,否则跑起来各种怪问题。

我曾经遇到过 Python 应用在 alpine 上跑 segfault,查了两天才发现是底层 C 扩展对 musl 兼容不好。后来老老实实换回了 slim 版本。

4.2 RUN:在构建时执行命令

RUN 就是在镜像构建过程中执行命令。每个 RUN 会创建一层新镜像。之前碰到有新手把一个 RUN 拆成十几个,结果镜像又大又慢。

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

注意上面的写法:用 && 链接多条命令,最后清理缓存。这是标准做法。为什么呢?因为每层都会保留文件变动,你把缓存删掉,但之前的层已经记录了,清理动作只是新加一层标记删除,文件实际还在。所以最好在同一层内完成安装加清理。

一个有用的技巧:合并 RUN 命令。
RUN command1 && command2 && command3
这样只产生一层,而不是三层。

我记得有一次排查镜像臃肿问题,发现里面有构建工具的依赖缓存,足足 800MB。就因为用了👇这种写法:

RUN apt-get install -y build-essential  # 这层产生了临时文件
RUN apt-get clean                        # 这层是新的一层,临时文件还在上一层保留着

所以务必在同一个 RUN 里做“安装 + 清理”。

4.3 COPY:把文件送进镜像

COPY 本地的文件、目录复制到镜像里。很多人觉得这指令简单,但其实踩坑最多。

COPY requirements.txt /app/
COPY . /app/

我在项目中踩过一个坑:把整个项目都 COPY 进去,然后用 RUN pip install。每次改一行代码,整个 COPY 层缓存就失效了,构建慢到怀疑人生。

避坑核心:把经常变动的文件放到 Dockerfile 靠后的位置。
把不常变动的文件(如 requirements.txt、package.json)放前面 COPY。

说白了,就是充分利用缓存。看这个例子:

# 先复制依赖文件,安装依赖
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt

# 最后才复制源码
COPY . /app/

你想想看,requirements.txt 不常改,只要它没变,这一层缓存就能用。只有源码改了,才重新构建最后一层。构建时间从 5 分钟降到 20 秒,一点不夸张。

另外,注意 COPY 和 ADD 的区别。ADD 可以解压 tar、从 URL 拉文件,但我建议能用 COPY 就别用 ADD。为什么呢?ADD 行为不够透明,容易产生意外。官方也是这么建议的。

我曾经见过有人用 ADD 从远程 URL 拉文件,build 时断网了,整个构建失败。而且 URL 文件没有缓存,每次都要重新下载。这种情况请用 curl 或 wget,可控性更强。

4.4 CMD:容器启动时的默认行为

CMD 是容器启动时执行的命令。但它不是构建时执行的,而是运行时。

CMD ["python", "app.py"]
CMD python app.py

两种写法,效果完全不同。我习惯用 exec 形式(就是数组形式),因为 shell 形式会多启动一个 sh 进程,信号传递有时会出问题。

  • exec 形式:["executable", "param1", "param2"] — 直接执行,PID 1 是目标进程。
  • shell 形式:executable param1 param2 — 用 /bin/sh -c 执行,PID 1 是 shell。
写法 形式 PID 1 信号传递
exec ["python", "app.py"] python 正常
shell python app.py /bin/sh 可能丢失 SIGTERM

我之前就被这个坑过一次:容器优雅关闭需要 30 秒,结果用 shell 形式写的 CMD,docker stop 发 SIGTERM 被 sh 吞了,进程直接被 SIGKILL 杀掉,数据都没来得及存。

一个实用建议:如果需要在 CMD 里使用环境变量,也可以用 exec 形式配合 sh -c 来达到变量替换:
CMD ["sh", "-c", "python app.py --port $PORT"]
注意,这么写的时候 PID 1 是 sh,需要确保信号传递。更稳妥的方式是用 ENTRYPOINT 配合 CMD,后面章节会讲到。

综合:一个实际可用的 Dockerfile

说白了,学指令最快的方式就是看一个完整的例子。

# 1. 选基础镜像
FROM python:3.11-slim

# 2. 设置工作目录
WORKDIR /app

# 3. 先复制依赖文件
COPY requirements.txt .

# 4. 安装依赖(利用缓存)
RUN pip install --no-cache-dir -r requirements.txt

# 5. 最后复制源码(经常变动的放最后)
COPY . .

# 6. 暴露端口
EXPOSE 8000

# 7. 容器启动命令
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

这个 Dockerfile 我一直在用,构建快、体积小、好维护。嗯,这里要注意的是每个指令的顺序都有讲究,别乱排。

Dockerfile 四大指令核心流程 FROM 指定基础镜像 RUN 构建时执行命令 COPY 文件进镜像 CMD 启动时执行 注意事项 基础镜像越小越好 缓存策略 不变文件先 COPY CMD 形式选择 优先用 exec 形式 最佳实践总结 合并 RUN 减少层数 · COPY 分顺序利用缓存 · CMD 选 exec 形式 同一个 RUN 内完成安装 + 清理 · 不用 ADD 远程 URL

总结一下今天的核心思路:

  • FROM:选准基础镜像,优先 alpine/slim
  • RUN:合并命令,同一层内做好清理
  • COPY:按变动频率排序,利用构建缓存
  • CMD:用 exec 形式,避免信号丢失

这四个指令掌握好了,Dockerfile 就算入门了。我建议你现在就去拉一个实际项目,重新写它的 Dockerfile,把今天讲的几个点都用上。自己踩一遍坑,比看十遍文档都管用。