Dockerfile 基础:结构、FROM、RUN、CMD、ENTRYPOINT 指令详解
说实话,刚开始用 Docker 那会儿,我最头疼的就是 Dockerfile。总觉得它就是个“菜谱”,怎么炒都行。直到有一次线上环境因为 CMD 和 ENTRYPOINT 混用导致容器启动失败,我才开始认真琢磨这几个指令的区别。嗯,这节课我们就一步步拆开来看。
Dockerfile 到底长什么样?
Dockerfile 是一份纯文本文件,里面每条指令都对应一次镜像层的构建。你可以把它理解成“逐层盖楼”的施工图——每一层都在上一层的文件系统上做增量修改。
我习惯把 Dockerfile 分成三块:基础描述、环境准备、启动设置。下面这个例子涵盖了最常用的几条指令:
# 第一块:基础描述
FROM ubuntu:22.04
LABEL maintainer="you@example.com"
# 第二块:环境准备
RUN apt-get update && apt-get install -y python3
COPY app.py /app/
WORKDIR /app
# 第三块:启动设置
EXPOSE 8080
CMD ["python3", "app.py"]
你看,每一块都有自己的职责。从 FROM 选基镜,到 RUN 装包,最后 CMD 告诉容器启动后该干什么。顺序别乱,我记得有同事把 COPY 放前面、FROM 放后面,直接报错——Dockerfile 第一条指令必须是 FROM,这是硬规则。
核心原则:每写一条指令,都会在镜像里多创建一层。层越多,构建越慢,镜像越大。所以我的建议是,能用一条 RUN 合并的命令,就别拆成三条。
FROM —— 你的镜像从哪起跑?
FROM 指定基础镜像。说白了,就是你先搭好一套最小化的操作系统环境,然后往上加东西。为什么需要基础镜像?因为你的应用几乎不可能从裸机空壳开始跑——至少得有个文件系统、包管理器、运行时库。
我个人喜欢用 alpine 做生产镜像,因为它小到只有 5MB。举个例子:
FROM alpine:3.18
RUN apk add --no-cache python3
这样打出来的镜像比 ubuntu:22.04 小十倍不止。但要注意,alpine 用的是 musl libc 而不是 glibc,有些 C 扩展可能不兼容。我之前有个 Python 项目在 alpine 上编译 scipy 直接报错,换成 python:3.11-slim 才解决——所以选择基础镜像时,一定要验证应用兼容性。
不要用 scratch 作为 FROM 除非你知道自己在做什么。你会发现除了空文件系统什么都没有,连 /bin/sh 都没有,很多命令跑不了。
RUN —— 你往镜像里装了啥?
RUN 负责在构建过程中执行命令,结果会永久写入镜像层。常用场景:安装软件包、编译代码、修改配置。
RUN 有两种形式:
- shell 形式:
RUN apt-get install -y curl,实际上是在/bin/sh -c里运行。 - exec 形式:
RUN ["apt-get", "install", "-y", "curl"],不走 shell,直接运行命令。
我在项目里常用 shell 形式,因为可以链式写多个命令:
RUN apt-get update && apt-get install -y \
build-essential \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
注意最后那行 rm -rf——这是清理 apt 缓存,减少镜像层大小。如果不清理,你每做一次 apt-get update 就把几百兆的列表塞进镜像里,白白浪费空间。
CMD 和 ENTRYPOINT —— 容器启动后做什么?
这两个指令经常让人混淆。我直接说结论:CMD 提供默认参数,ENTRYPOINT 定义主程序。
| 特性 | CMD | ENTRYPOINT |
|---|---|---|
| 作用 | 设置容器启动后的默认命令及参数 | 设定容器启动后的主进程 |
| 是否可以覆盖 | 运行容器时 docker run 加的命令可以完全替代 | 需要 --entrypoint 参数或联合 CMD 来修改参数 |
| 推荐用法 | 为 ENTRYPOINT 提供默认参数 | 固定主程序,如 ["nginx", "-g", "daemon off;"] |
| 常见陷阱 | 如果写多条 CMD,只有最后一条生效 | 与 CMD 混用时容易理解错覆盖规则 |
看个组合使用的例子:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# ENTRYPOINT 固定脚本,CMD 提供默认参数
ENTRYPOINT ["python", "app.py"]
CMD ["--port=8080"]
这样设计的好处:默认启动监听 8080 端口;用户也可以 docker run myimage --port=9090 来覆盖端口参数,而主程序 python app.py 不会被改掉。
小技巧:如果想让容器既能被当作工具执行特定命令(如 docker run myimage ls),又不想让主程序被意外替换,就不要用 ENTRYPOINT,只写 CMD 反而是最灵活的。
避坑指南
- 我曾经在 Dockerfile 里写了三条 CMD,调试了半小时才发现只有最后一条生效。牢记:每个 Dockerfile 中建议只保留一条 CMD,否则后续会覆盖前一条。
- 我曾经在 ENTRYPOINT 里用 shell 形式
ENTRYPOINT python app.py,结果容器里进程信号传不过去,优雅关闭直接失效。原因:shell 形式会启动一个/bin/sh -c python app.py,而sh不会转发 SIGTERM。建议用 exec 形式ENTRYPOINT ["python", "app.py"]。 - 如果你同时使用 CMD 和 ENTRYPOINT,且两者都是 shell 形式,Docker 会报错——因为 shell 形式默认用
/bin/sh -c包装,无法正确拼接参数。保持至少一个使用 exec 形式可以解决。
知识结构总览
下面这张图把 Dockerfile 核心指令的关系画清楚了,方便你对照理解:
从上到下就是 Dockerfile 构建的完整链路:先选基础镜像,然后通过 RUN 安装依赖、复制代码、设置环境,最后用 CMD 或 ENTRYPOINT 告诉容器启动后该执行什么。记住,构建阶段和运行阶段是分离的——RUN 的结果固化在镜像里,CMD 只在容器启动时生效。
刚开始写 Dockerfile 时,可以只用 CMD 调试,等流程稳定后再引入 ENTRYPOINT 做精细控制。一句话:先跑起来,再优化。