Dockerfile 编写入门:FROM、RUN、COPY 等指令
Dockerfile 是什么?说白了,就是一份「镜像说明书」。你告诉 Docker:用哪个基础镜像、装什么软件、复制哪些文件、启动时跑什么命令。我第一次写 Dockerfile 时,觉得这东西跟写脚本没啥区别。后来被生产环境坑过几次,才明白每行指令都藏着学问。今天咱们就把最核心的几个指令捋一遍。
FROM:一切的起点
几乎每个 Dockerfile 都以 FROM 开头。它指定了你的镜像从哪个基础镜像开始构建。可以想象成装修房子,你得先有毛坯房。
我个人习惯:能用官方镜像就别用第三方。比如 Python 项目,直接从 python:3.11-slim 开始,干净又省心。
这里有个坑:基础镜像的 tag 一定要写完整,不要只写 python:latest。latest 会变,你今天构建没问题,下周可能 base 镜像升级了,依赖炸掉。
我曾经有次急着上线,没指定 tag,结果第二天 CI 里就报缺少某个底层库。从那以后,我所有 Dockerfile 里的 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,能省不少空间。
\,否则会当成两个指令。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 秒。
COPY . /app 写在了 RUN pip install 的前面。结果每次改一行代码,整个依赖层都重装,构建慢得让人崩溃。正确的顺序:先拷贝不常变的文件(如 requirements.txt),再安装依赖,最后拷贝源码。这样改动代码时只重建最后几层。好了,Dockerfile 的入门指令就这些。记住:FROM 定根基,RUN 装环境,COPY 传文件,CMD/ENTRYPOINT 定行为。再加个 .dockerignore 防坑,你的镜像就能优雅又高效了。下一块我们聊聊如何用 Docker Compose 编排多个服务——但那是后面的章节了。