Dockerfile使用详解:常用指令、构建原理与最佳实践
Dockerfile 是什么,解决了什么问题
Dockerfile 是一个用于描述镜像构建过程的文本文件。它把“安装什么依赖、复制哪些文件、暴露哪些端口、容器启动时执行什么命令”这些操作固化成可重复执行的构建步骤。
在没有 Dockerfile 之前,很多团队构建运行环境的方式往往是:
- 手工进入服务器安装依赖
- 手工复制代码
- 手工修改配置
- 手工启动服务
这种方式的问题很明显:不一致、不可追溯、难以复现。 而 Dockerfile 的价值就是把环境构建过程代码化,做到:
- 开发、测试、生产环境尽量一致
- 镜像构建过程可审计、可版本管理
- 新成员可以快速复现运行环境
- CI/CD 可以自动化构建与发布
Dockerfile 的基本构建流程
当执行下面的命令时:
docker build -t myapp:1.0 .
Docker 会在当前目录中查找 Dockerfile,然后按照文件中的指令从上到下逐条执行,最终生成一个镜像。
一个典型过程是:
- 选择基础镜像
- 安装系统依赖或运行时
- 拷贝业务代码
- 设置环境变量
- 暴露端口
- 指定容器启动命令
例如:
FROM python:3.11-slim
WORKDIR /app
COPY . /app
RUN pip install -r requirements.txt
EXPOSE 8000
CMD ["python", "app.py"]
这 6 行就描述了一个最小可运行的 Python 服务镜像。
Dockerfile 中最常用的指令详解
FROM:指定基础镜像
FROM 是 Dockerfile 的第一条有效指令,用来指定当前镜像基于哪个镜像构建。
FROM nginx:1.27
也可以指定更轻量的镜像:
FROM python:3.11-slim
使用建议
- 尽量使用明确版本号,不要长期依赖
latest - 优先选择官方镜像
- 在满足需求的前提下选择更小的基础镜像,如
slim、alpine
为什么不要滥用 latest
FROM node:latest
这种写法的风险在于:同一份 Dockerfile,今天构建和下个月构建,拿到的可能不是同一个基础环境,容易导致“昨天还能跑,今天突然出问题”。
更稳妥的方式:
FROM node:20.12-slim
WORKDIR:设置工作目录
WORKDIR 用于指定后续指令的执行目录。如果目录不存在,会自动创建。
WORKDIR /app
之后的 COPY、RUN、CMD 默认都会在这个目录上下文中执行。
例如:
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
等价于进入 /app 目录后再执行这些命令。
为什么推荐使用 WORKDIR,而不是 RUN cd
错误示例:
RUN cd /app && pip install -r requirements.txt
原因在于每个 RUN 都是一个新的构建层,前一个 RUN 中的 cd 状态不会自动保留。 所以更推荐:
WORKDIR /app
RUN pip install -r requirements.txt
COPY:复制文件到镜像中
COPY 是最常用的文件复制指令。
COPY . /app
表示把当前构建上下文中的所有文件复制到镜像中的 /app 目录。
也可以精确复制:
COPY requirements.txt /app/
COPY src/ /app/src/
推荐做法:先复制依赖清单,再复制源码
例如 Python 项目:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
这样做的好处是:当业务代码变化,但依赖文件没有变化时,Docker 可以复用依赖安装这一层缓存,提高构建速度。
ADD:功能比 COPY 多,但不建议滥用
ADD 除了复制文件,还支持:
- 自动解压本地压缩包
- 从 URL 下载资源(不推荐依赖)
例如:
ADD app.tar.gz /app/
COPY 和 ADD 的区别
| 指令 | 主要用途 | 是否推荐 |
|---|---|---|
| COPY | 纯粹复制文件 | 推荐,语义清晰 |
| ADD | 复制 + 解压 + 远程下载 | 谨慎使用 |
多数场景下,优先使用 COPY。只有明确需要自动解压等行为时再考虑 ADD。
RUN:构建镜像时执行命令
RUN 用于在镜像构建阶段执行命令,常见用途有:
- 安装系统包
- 安装语言依赖
- 编译代码
- 清理缓存
例如:
RUN apt-get update && apt-get install -y curl
Shell 格式与 Exec 格式
Shell 格式
RUN apt-get update && apt-get install -y curl
由 shell 解析,写法直观,适合链式命令。
Exec 格式
RUN ["apt-get", "update"]
使用 JSON 数组格式,避免 shell 解析差异,但复杂命令可读性稍弱。
多数情况下,RUN 使用 shell 格式更常见。
RUN 的优化建议
把相关命令尽量合并,减少镜像层数:
RUN apt-get update && \
apt-get install -y curl vim && \
rm -rf /var/lib/apt/lists/*
这样既减少层数,又能顺手清理缓存,避免镜像体积膨胀。
CMD:指定容器默认启动命令
CMD 定义的是容器启动时默认执行的命令。
CMD ["python", "app.py"]
一个 Dockerfile 中通常只有最后一个 CMD 生效。
两种写法
Exec 格式,推荐
CMD ["nginx", "-g", "daemon off;"]
Shell 格式
CMD python app.py
推荐使用 Exec 格式,因为它的信号处理更直接,也更适合生产环境。
ENTRYPOINT:定义固定入口命令
ENTRYPOINT 和 CMD 很容易混淆。
ENTRYPOINT:定义容器的固定入口CMD:定义默认参数,或默认命令
示例:
ENTRYPOINT ["python"]
CMD ["app.py"]
容器启动时实际执行的是:
python app.py
如果运行:
docker run myapp other.py
那么 CMD 会被替换,最终执行:
python other.py
什么时候用 ENTRYPOINT
当你希望镜像始终表现得像一个固定工具时,适合使用 ENTRYPOINT。 例如某镜像的本质就是执行 python、java、nginx,这时 ENTRYPOINT 很自然。
CMD 和 ENTRYPOINT 的关系
最常见组合是:
ENTRYPOINT ["gunicorn"]
CMD ["app:app", "-b", "0.0.0.0:8000"]
这样用户既能直接运行服务,也能在 docker run 时覆盖参数。
ENV:设置环境变量
ENV 用于在镜像中设置环境变量。
ENV APP_ENV=production
ENV PORT=8000
容器运行时可以读取这些变量。
例如 Python:
import os
port = os.getenv("PORT", "8000")
ENV 与 ARG 的区别
ENV:镜像中长期存在,运行时也可见ARG:只在构建阶段可用,镜像运行时不可见
ARG:构建时参数
ARG 用于在 docker build 时传参。
ARG APP_VERSION=1.0.0
RUN echo "building version: ${APP_VERSION}"
构建时可以覆盖:
docker build --build-arg APP_VERSION=1.2.3 -t myapp:1.2.3 .
典型用途
- 传入版本号
- 选择构建环境
- 控制依赖源或编译参数
但要注意:ARG 不适合存储敏感信息,因为它可能出现在镜像历史中。
EXPOSE:声明容器监听端口
EXPOSE 8080
EXPOSE 的作用是文档化告诉使用者:这个容器预期监听 8080 端口。 它不会自动把宿主机端口映射出来。
真正对外发布端口仍然需要:
docker run -p 8080:8080 myapp
USER:指定容器运行用户
默认情况下,容器通常以 root 用户运行。生产环境中这并不安全。
RUN useradd -m appuser
USER appuser
这样容器中的应用将以普通用户身份运行,能降低误操作和安全风险。
为什么生产环境推荐非 root
- 减少容器被入侵后的权限范围
- 避免应用直接修改敏感系统文件
- 更符合最小权限原则
VOLUME:声明挂载点
VOLUME ["/data"]
表示 /data 是一个可挂载的数据卷目录,适合存储需要持久化的数据。
不过在现代项目中,很多团队更习惯在运行时通过 docker run -v 或 docker-compose.yml / compose.yaml 显式管理卷,而不是完全依赖 Dockerfile 中的 VOLUME。
HEALTHCHECK:定义健康检查
健康检查用于让 Docker 判断容器是否真正可用,而不只是“进程还活着”。
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
这在 Web 服务中非常有用。因为很多服务虽然进程还在,但实际上已经无法对外提供请求。
Dockerfile 的分层机制与缓存原理
Dockerfile 每执行一条会生成文件系统变更的指令,通常就会形成一个新的镜像层。 这也是为什么 Docker 构建速度有时很快,有时很慢:缓存是否命中非常关键。
例如:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
当你只改了 app.py,重新构建时:
FROM命中缓存WORKDIR命中缓存COPY requirements.txt命中缓存RUN pip install -r requirements.txt命中缓存COPY . .这层失效CMD重新应用
这就是为什么依赖文件应该尽早单独复制,而业务代码后复制。
缓存优化的核心原则
- 变化少的步骤写前面
- 变化频繁的步骤写后面
- 大量依赖安装尽量依赖固定文件
- 构建上下文尽量小,避免无关文件导致缓存失效
.dockerignore 为什么很重要
很多人写 Dockerfile 时只关注指令本身,却忽略了 .dockerignore。 这个文件的作用类似 .gitignore,用于告诉 Docker 构建时不要把哪些文件发送到构建上下文。
示例:
.git
node_modules
__pycache__
*.log
dist
build
.env
不写 .dockerignore 的后果
- 构建上下文变大,上传到 Docker daemon 更慢
- 无关文件进入镜像,镜像体积膨胀
- 敏感文件误复制进镜像
- 无意义文件变化导致缓存频繁失效
.dockerignore 是 Dockerfile 优化里非常容易被低估的一环。
一个完整示例:Python Web 服务的 Dockerfile
下面是一个更接近实际项目的例子:
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && \
apt-get install -y --no-install-recommends curl gcc && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["python", "app.py"]
这份 Dockerfile 做了什么
- 基于轻量级 Python 镜像
- 设置工作目录为
/app - 配置 Python 运行环境变量
- 安装必要系统依赖
- 先安装 Python 依赖以利用缓存
- 复制业务代码
- 切换到非 root 用户
- 暴露 8000 端口
- 增加健康检查
- 指定服务启动命令
多阶段构建:构建镜像瘦身的关键技巧
很多项目在构建阶段需要安装编译工具,但运行阶段并不需要。 如果把这些工具全部留在最终镜像中,体积会很大。
这时就可以使用多阶段构建。
示例:
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN go build -o app main.go
FROM debian:stable-slim
WORKDIR /app
COPY --from=builder /src/app /app/app
EXPOSE 8080
CMD ["/app/app"]
多阶段构建的优点
- 最终镜像更小
- 运行环境更干净
- 减少攻击面
- 提高分发效率
适用场景
- Go、Rust、C/C++ 编译型项目
- 前端项目先打包静态资源,再放入 Nginx
- Java 项目先构建 JAR,再放入运行时镜像
- Python 项目中需要复杂构建依赖时也可分阶段处理
Dockerfile 编写最佳实践
1. 基础镜像尽量精简且明确版本
不推荐:
FROM ubuntu
更推荐:
FROM python:3.11-slim
2. 优先使用 COPY,而不是 ADD
大多数时候你只需要复制文件,不需要额外副作用。
3. 利用缓存优化构建速度
把依赖安装与源码复制分开:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
4. 合并 RUN 指令并及时清理缓存
例如安装 apt 包时:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
5. 不要把敏感信息写进镜像
错误示例:
ENV DB_PASSWORD=123456
这类信息不应该固化在镜像里,而应在运行时通过环境变量、密钥管理系统或编排平台注入。
6. 尽量使用非 root 用户运行应用
这是生产环境里很重要的一条基线。
7. 为服务镜像添加健康检查
尤其是 Web 服务、API 服务、消息消费服务,健康检查能帮助平台更准确判断实例状态。
8. 配合 .dockerignore 控制构建上下文
不要把无关目录一起送去构建。
9. 一个镜像只做一件主要事情
一个容器里既跑数据库、又跑 Nginx、又跑业务服务,通常不是好设计。 更合理的方式是一个镜像承载一个主要职责,再通过编排工具协同。
10. 固定依赖版本,增强可复现性
例如:
- Python 的
requirements.txt - Node.js 的
package-lock.json - Java 的明确 JDK/JRE 版本
- 系统包的明确来源与版本策略
这样更利于构建结果稳定。
常见问题与易踩坑
1. CMD 写了多个,为什么只有最后一个生效
因为 Dockerfile 中同类指令通常以后者覆盖前者,CMD 只保留最后一个。
2. EXPOSE 了端口,为什么还是访问不到
因为 EXPOSE 只是声明端口,不会自动映射到宿主机。还需要:
docker run -p 8000:8000 myapp
3. RUN cd /app 后,下一行为什么不在 /app 目录
因为每个 RUN 都在独立层中执行,目录状态不会自然延续。 应该使用 WORKDIR。
4. 为什么镜像体积特别大
常见原因有:
- 基础镜像太大
- 安装了大量不必要工具
- 没有清理包管理器缓存
- 把
node_modules、构建产物、日志等都复制进去了 - 没有使用多阶段构建
5. 为什么每次改一行代码都要重新安装全部依赖
通常是因为 Dockerfile 顺序不合理,例如直接先 COPY . .,导致依赖安装层缓存失效。
错误示例:
COPY . .
RUN pip install -r requirements.txt
更合理:
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
6. Shell 格式和 Exec 格式该怎么选
经验上可以这样理解:
RUN常用 shell 格式,便于写链式命令CMD、ENTRYPOINT优先使用 exec 格式,利于信号处理和参数传递
如何读懂一个 Dockerfile
当你拿到一份陌生 Dockerfile 时,可以按下面顺序快速分析:
- 看
FROM:确定运行时基础环境 - 看
WORKDIR:确认项目目录 - 看
COPY/ADD:确认哪些文件进入镜像 - 看
RUN:确认安装了哪些依赖、做了哪些构建 - 看
ENV/ARG:确认配置项来源 - 看
EXPOSE:确认服务端口 - 看
USER:确认运行身份 - 看
CMD/ENTRYPOINT:确认容器启动方式
这套阅读顺序能帮助你快速判断镜像的职责、运行方式和优化空间。
Dockerfile 与镜像、容器的关系
很多初学者容易混淆这三个概念:
- Dockerfile:构建说明书
- 镜像 Image:按照说明书构建出来的静态产物
- 容器 Container:镜像启动后的运行实例
可以简单理解为:
- Dockerfile 像“菜谱”
- 镜像像“半成品套餐”
- 容器像“真正端上桌并开始食用的菜”
理解这个关系后,再看 docker build 和 docker run 的区别就很清晰了:
docker build:根据 Dockerfile 生成镜像docker run:根据镜像启动容器
总结
Dockerfile 的本质不是“写几条命令把程序跑起来”,而是把运行环境标准化、可复现化、自动化。
真正写好 Dockerfile,关键不在于记住多少条指令,而在于理解三个核心点:
- 镜像是分层构建的,顺序会影响缓存与体积
- 容器运行环境应该尽量最小化,减少体积和风险
- 构建过程要可重复、可维护、可审计
在实际项目中,建议至少做到以下基线:
- 固定基础镜像版本
- 合理利用缓存
- 使用
.dockerignore - 尽量非 root 运行
- 避免把敏感信息写进镜像
- 优先采用多阶段构建优化体积
只要掌握这些原则,你就不仅能“写出能用的 Dockerfile”,还能写出适合生产环境的 Dockerfile。