Dockerfile 编写入门:FROM、RUN、COPY 等指令

Dockerfile 是什么?说白了,就是一份「镜像说明书」。你告诉 Docker:用哪个基础镜像、装什么软件、复制哪些文件、启动时跑什么命令。我第一次写 Dockerfile 时,觉得这东西跟写脚本没啥区别。后来被生产环境坑过几次,才明白每行指令都藏着学问。今天咱们就把最核心的几个指令捋一遍。

FROM:一切的起点

几乎每个 Dockerfile 都以 FROM 开头。它指定了你的镜像从哪个基础镜像开始构建。可以想象成装修房子,你得先有毛坯房。

我个人习惯:能用官方镜像就别用第三方。比如 Python 项目,直接从 python:3.11-slim 开始,干净又省心。

这里有个坑:基础镜像的 tag 一定要写完整,不要只写 python:latest。latest 会变,你今天构建没问题,下周可能 base 镜像升级了,依赖炸掉。
我曾经有次急着上线,没指定 tag,结果第二天 CI 里就报缺少某个底层库。从那以后,我所有 Dockerfile 里的 FROM 都锁死版本号。

小技巧:多阶段构建时,可以写两个 FROM。一个用来编译,一个用来运行,最终镜像能小不少。后面我会专门讲。

RUN:在镜像里执行命令

RUN 用来安装软件、创建目录、跑脚本。每执行一次 RUN,Docker 就会生成一个新的镜像层。

你可能会问:层多了会怎样?镜像体积变大,构建时间变长。我在维护一个老旧项目时,发现它的 Dockerfile 写了七八个 RUN,每个 RUN 就装一个包。结果镜像 2 个多 G,部署慢得离谱。

解决办法:合并 RUN 指令。把多个命令用 && 串起来。比如:

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

这样只产生一层,而且末尾清理了缓存。我建议每个 RUN 都带上 apt-get clean 和 --no-install-recommends,能省不少空间。

注意:RUN 的换行要用反斜杠 \,否则会当成两个指令。Dockerfile 里每行指令都是独立的上下文。

COPY 与 ADD:把文件塞进去

这两个指令都是将本地文件复制到镜像里。区别呢?
COPY 就是单纯的复制。ADD 多了一些功能:支持自动解压 tar 包、支持从 URL 下载。
但我几乎只用 COPY。为什么?

指令功能我的推荐
COPY复制文件/目录首选,行为透明,不会干多余的事
ADD复制 + 自动解压 tar + URL 下载仅当需要自动解压时使用

用 ADD 解压 tar 包很方便,但很多人不知道:如果源文件更新,ADD 的缓存策略会失效,导致后续所有层重做。我碰到过同事用 ADD 下载一个外部 zip,结果每次构建都重新下载,浪费大量 CI 时间。改成 COPY 加手动解压后,缓存就正常了。

所以我的原则:能用 COPY 绝不用 ADD。解压操作放到 RUN 里,可控性更高。

# 推荐做法
COPY app.tar.gz /tmp/
RUN tar xzf /tmp/app.tar.gz -C /opt/app && rm /tmp/app.tar.gz

CMD 与 ENTRYPOINT:容器启动后干什么

这两个指令定义了容器启动时运行的命令。很多人搞不清区别,我画个对比:

指令行为docker run 加参数时
CMD ["python", "app.py"]默认启动命令参数会替换 CMD
ENTRYPOINT ["python"]不可覆盖的入口参数追加到 ENTRYPOINT 后面
两者结合ENTRYPOINT 固定,CMD 提供默认参数最灵活,推荐使用

我最常用的模式:ENTRYPOINT 放可执行文件,CMD 放默认参数。比如:

ENTRYPOINT ["python", "run_pipeline.py"]
CMD ["--input", "/data", "--output", "/results"]

这样用户想覆盖参数时,直接 docker run --input /custom 就行,而入口程序不会变。我之前的项目就是用这种方式,让用户既能一键运行,又能灵活定制。

WORKDIR、ENV、EXPOSE 等辅助指令

  • WORKDIR:设置工作目录。如果目录不存在会自动创建。我习惯在 COPY 之前先 WORKDIR,这样后续指令都基于这个路径。
  • ENV:设置环境变量。比如 ENV PYTHONUNBUFFERED=1,避免 Python 日志被缓冲。
  • EXPOSE:声明容器监听端口。仅仅是文档作用,真正映射得靠 -p。
  • USER:指定运行用户。安全起见,别用 root。我经常建一个 appuser,然后切换过去。

构建一个实际镜像的例子

我们拿一个简单的 Python 数据分析工具为例,把以上指令串起来:

FROM python:3.11-slim-bookworm AS base

WORKDIR /app

# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# 先复制依赖文件,利用缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制应用代码
COPY src/ ./src/

# 创建非 root 用户
RUN useradd -m appuser
USER appuser

EXPOSE 8080
CMD ["python", "src/main.py"]

构建并运行:

docker build -t my-analysis:1.0 .
docker run --rm my-analysis:1.0
关键点:先把 requirements.txt 复制进去并安装依赖,再复制源代码。因为依赖通常不常变,这样能利用 Docker 构建缓存,每次改代码后只重新构建后面几层,速度飞快。

构建缓存与 .dockerignore

Docker 构建时,每层都会缓存。如果某一层没变,就直接复用上次的结果。但有个魔鬼细节:COPY 指令会检查文件内容和修改时间,如果 . 目录里包含大量临时文件、日志、git 目录,缓存会频繁失效。

所以 .dockerignore 文件必不可少:

__pycache__/
.git/
*.swp
*.pyc
.env
data/raw/

我有个惨痛经历:项目里有个 data/raw/ 文件夹,里面几百个客户原始数据文件。每次构建时,这些文件被 COPY 进镜像,镜像包变得巨大,而且每次 data 变动都会触发全部重新构建。后来加了一行 data/raw/ 到 .dockerignore,构建时间从 5 分钟降到 10 秒。

经验:.dockerignore 里最好排除所有构建时不需要的东西。你可以在项目根目录写一个详细的 ignore 列表,我通常把 .git、__pycache__、*.log、.vscode 等全都放进去。
Dockerfile 构建流程与指令关系 FROM 基础镜像 RUN 安装依赖 COPY 代码文件 WORKDIR / ENV 配置 EXPOSE 暴露端口 最终镜像(多层) ⚠ 每一条 RUN/COPY 指令都会产生一个新层。合并 RUN、精确控制 COPY 范围, 配合 .dockerignore 和缓存策略,是写出高效 Dockerfile 的关键。
避坑指南:我曾经在 Dockerfile 里把 COPY . /app 写在了 RUN pip install 的前面。结果每次改一行代码,整个依赖层都重装,构建慢得让人崩溃。正确的顺序:先拷贝不常变的文件(如 requirements.txt),再安装依赖,最后拷贝源码。这样改动代码时只重建最后几层。

好了,Dockerfile 的入门指令就这些。记住:FROM 定根基,RUN 装环境,COPY 传文件,CMD/ENTRYPOINT 定行为。再加个 .dockerignore 防坑,你的镜像就能优雅又高效了。下一块我们聊聊如何用 Docker Compose 编排多个服务——但那是后面的章节了。