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,否则跑起来各种怪问题。
4.2 RUN:在构建时执行命令
RUN 就是在镜像构建过程中执行命令。每个 RUN 会创建一层新镜像。之前碰到有新手把一个 RUN 拆成十几个,结果镜像又大又慢。
RUN apt-get update && apt-get install -y \
curl \
git \
vim \
&& rm -rf /var/lib/apt/lists/*
注意上面的写法:用 && 链接多条命令,最后清理缓存。这是标准做法。为什么呢?因为每层都会保留文件变动,你把缓存删掉,但之前的层已经记录了,清理动作只是新加一层标记删除,文件实际还在。所以最好在同一层内完成安装加清理。
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 层缓存就失效了,构建慢到怀疑人生。
把不常变动的文件(如 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 行为不够透明,容易产生意外。官方也是这么建议的。
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 杀掉,数据都没来得及存。
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 我一直在用,构建快、体积小、好维护。嗯,这里要注意的是每个指令的顺序都有讲究,别乱排。
总结一下今天的核心思路:
- FROM:选准基础镜像,优先 alpine/slim
- RUN:合并命令,同一层内做好清理
- COPY:按变动频率排序,利用构建缓存
- CMD:用 exec 形式,避免信号丢失
这四个指令掌握好了,Dockerfile 就算入门了。我建议你现在就去拉一个实际项目,重新写它的 Dockerfile,把今天讲的几个点都用上。自己踩一遍坑,比看十遍文档都管用。