Dockerfile 详解
Dockerfile 是 Docker 的灵魂——一个文本文件,描述"怎么从一个基础镜像,一步步构建出你自己的应用镜像"。掌握了它,你就能把任何项目打包成"扔到任何机器都能跑"的镜像。本章把全部常用指令讲透,并解释让构建又快又小的"分层缓存"原理。
一个完整模板
先看一个典型 Node.js 项目的 Dockerfile,基本结构一网打尽:
# 一个典型 Node.js 项目的 Dockerfile,逐行注释
# 基础镜像:从哪个现成镜像开始构建(必须是第一条非注释指令)
FROM node:20-alpine
# 设置工作目录(容器内的路径,不存在会自动创建)
WORKDIR /app
# 只复制依赖清单(利用缓存层,见后文)
COPY package*.json ./
# 安装生产依赖(--omit=dev 不装 devDependencies)
RUN npm ci --omit=dev
# 复制源码(放后面,因为源码常变,会让后续缓存失效)
COPY . .
# 构建产物(如果是 TypeScript / 前端项目)
RUN npm run build
# 声明容器监听的端口(仅文档作用,运行时还要 -p 映射)
EXPOSE 3000
# 设环境变量(运行时仍可被 docker run -e 覆盖)
ENV NODE_ENV=production
# 容器启动时默认执行的命令
CMD ["node", "dist/main.js"]这个文件名叫 Dockerfile(无扩展名,首字母大写),放在项目根目录。配套一个 .dockerignore(见后文)。写好后 docker build 一下就生成镜像。
构建、运行、调试
# 构建、运行、调试 Dockerfile
# 构建镜像(注意最后的点表示"上下文目录")
docker build -t myapp:1.0 .
# 镜像名:标签 上下文(决定了 COPY 能复制什么)
# 指定 Dockerfile 路径(默认找 ./Dockerfile)
docker build -f Dockerfile.prod -t myapp:1.0 .
# 不使用缓存(排查缓存问题用)
docker build --no-cache -t myapp:1.0 .
# 构建参数(ARG)
docker build --build-arg NODE_VERSION=18 -t myapp:1.0 .
# 多平台构建(Apple Silicon 上构建 amd64 镜像)
docker build --platform linux/amd64 -t myapp:1.0 .
# 跑起来验证
docker run -d --name myapp -p 3000:3000 myapp:1.0上下文(命令最后的那个点)是新手最容易忽略的概念。它告诉 Docker "构建时允许访问哪个目录的文件"——COPY . . 复制的就来自这个范围。把 Dockerfile 放项目根、上下文设为 . 是最常见做法。注意上下文越大,构建越慢(全部内容会打包发给守护进程),所以一定要配 .dockerignore。
FROM:基础镜像
FROM 必须是 Dockerfile 的第一条非注释指令。选择基础镜像是镜像大小和安全的关键决策:
- scratch:空镜像,0 字节。适合静态编译的 Go/Rust 程序,能做出几 MB 的极小镜像。
- alpine:5MB 的 Alpine Linux,生产首选。
- slim:精简 Debian,几十到一百多 MB,兼容性好。
- 官方完整版:如
node:20,几百 MB,装满工具链,适合开发。
原则:越精简越好,但要保证依赖能装上。多阶段构建(见最佳实践章)能在编译时用完整镜像、运行时用 alpine,两全其美。
RUN:执行命令
RUN 在构建时执行命令,产出新的一层。它是 Dockerfile 里最常用、也最容易写出"胖镜像"的指令:
- 每条
RUN产生一层,层数越多镜像越大。合并多个命令用&&连接,减少层数。 - 装完包后清理缓存(apt/yum/apk 的缓存),否则缓存会留在镜像里。
- shell 形式
RUN apt install -y x通过 /bin/sh 执行;exec 形式RUN ["apt", "install", "-y", "x"]直接执行,不依赖 shell。
正确写法示例:把装包、清理一行搞定。
# RUN 合并写法:多条命令用 && 串联,减少层数 + 清理缓存
# ❌ 错误写法:三条 RUN,产生三层,缓存也留着
RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*
# ✅ 正确写法:一条 RUN 完成,只有一层,缓存即装即清
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl git \
&& rm -rf /var/lib/apt/lists/*
# alpine 用 apk,清理用 --no-cache 或 rm /var/cache/apk/*
RUN apk add --no-cache curl git
# 写法要点:
# 1. 用 && 连接,前一条失败后一条不执行(避免半成品层)
# 2. 用 \ 换行让长命令更可读(Dockerfile 行续符)
# 3. --no-install-recommends / --no-cache 减少多余文件COPY vs ADD:复制文件
# COPY vs ADD
# COPY:复制本地文件到镜像(简单可靠,推荐)
COPY package.json /app/
COPY package*.json /app/
COPY src/ /app/src/ # 复制整个目录
COPY . . # 复制上下文所有文件(配合 .dockerignore)
# ADD:COPY 的超集,多了三个能力
ADD https://example.com/app.tar.gz /tmp/ # 自动下载 URL
ADD app.tar.gz /app/ # 自动解压本地 tar.gz / zip
ADD app.tar.gz /app/ # 但不会自动解压从 URL 下载的压缩包
# 最佳实践:永远优先用 COPY
# 只有需要自动解压本地 tar 包时才用 ADD
# 想下载远程文件?用 RUN curl + COPY 更可控
# USER:以非 root 用户运行(安全)
RUN addgroup -S app && adduser -S app -G app
USER app永远优先用 COPY。它语义清晰、行为可预测。ADD 的"自动下载 URL"和"自动解压"功能看似方便,但容易让构建依赖网络状态、产物不可控。需要远程文件就 RUN curl -o ...。
CMD vs ENTRYPOINT:启动命令
这是 Dockerfile 里最容易混淆的一对。理解关键是"谁会被 docker run 后面的参数覆盖":
# CMD vs ENTRYPOINT(新手最容易混淆)
# CMD:容器默认命令,可被 docker run 后面的参数覆盖
FROM alpine
CMD ["echo", "hello"]
# docker run myimage → 输出 hello
# docker run myimage echo hi → 输出 hi(CMD 被覆盖!)
# ENTRYPOINT:容器入口,不会被覆盖,docker run 后的参数会作为它的参数
FROM alpine
ENTRYPOINT ["echo"]
# docker run myimage → 报错(echo 没参数)
# docker run myimage hello → 输出 hello(hello 作为 echo 的参数)
# 经典组合:ENTRYPOINT 定主程序,CMD 给默认参数
FROM alpine
ENTRYPOINT ["ping"]
CMD ["localhost"]
# docker run myimage → ping localhost
# docker run myimage 8.8.8.8 → ping 8.8.8.8(CMD 被覆盖,主程序不变)
# shell 形式 vs exec 形式
CMD echo hi # shell 形式,会通过 /bin/sh -c 执行
CMD ["echo", "hi"] # exec 形式,推荐(能收到 SIGTERM 信号)记住经典组合:ENTRYPOINT 定主程序,CMD 给默认参数。这样既能开箱即用,又能灵活传参。还要注意 exec 形式优于 shell 形式——shell 形式会让命令成为 /bin/sh 的子进程,收不到 SIGTERM 信号,stop 时只能被 SIGKILL,无法优雅退出。
WORKDIR、ENV、EXPOSE、USER
# 环境变量与构建参数
# ENV:运行时环境变量(容器内进程能读到)
ENV NODE_ENV=production
ENV LOG_LEVEL=info
ENV PATH="/app/node_modules/.bin:$PATH"
# ARG:构建时变量(只在构建期间可见,运行时不存在)
ARG NODE_VERSION=20
FROM node:$NODE_VERSION-alpine
ARG BUILD_NUMBER
RUN echo "Build $BUILD_NUMBER" > /app/version.txt
# 多个 ENV 写一起(更省层)
ENV NODE_ENV=production \
LOG_LEVEL=info \
PORT=3000
# 运行时覆盖 ENV
docker run -e NODE_ENV=staging myapp# 其他常用指令
# WORKDIR:设置后续指令的工作目录(等于 cd,推荐用绝对路径)
WORKDIR /app
RUN pwd # 输出 /app
# EXPOSE:声明端口(仅文档/提示作用,不真正映射)
EXPOSE 3000
EXPOSE 80/tcp 443/tcp
# 真正对外访问还要 docker run -p 3000:3000
# VOLUME:声明匿名卷挂载点(运行时若不 -v,Docker 自动分配)
VOLUME /app/data
VOLUME ["/app/data", "/app/logs"]
# HEALTHCHECK:让 Docker 自动检测容器健康
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# 不健康会显示在 docker ps 的 STATUS 里:Up 5 minutes (unhealthy)几个要点:
WORKDIR推荐绝对路径,且会自动创建。不要用RUN cd /app,因为每条 RUN 是独立的 shell,cd 不影响下一条。EXPOSE是文档性的,不会真正开端口。真正对外访问必须docker run -p。它的作用是让镜像使用者知道容器监听哪些端口。USER切换后续指令的执行用户。生产镜像不应以 root 跑,即使容器被攻破,攻击者也只是普通用户权限。HEALTHCHECK让 Docker 主动检测容器健康状态,不健康会被标记(配合--restart或编排工具自动重启)。
.dockerignore:必配文件
# .dockerignore:排除无关文件(类似 .gitignore)
# 放在构建上下文目录,与 Dockerfile 同级
# 内容示例:
node_modules
npm-debug.log
.git
.gitignore
Dockerfile
docker-compose*.yml
.env
.env.local
dist
build
*.md
.vscode
.idea
# 好处:
# 1. 加速构建(少传无关文件给守护进程)
# 2. 避免意外把密钥、缓存打进镜像
# 3. 让 COPY . . 的语义更可控
# 没有这个文件,COPY . . 会把整个目录(包括 node_modules!)都打进镜像没配 .dockerignore 就 COPY . . 是新手常犯的大错——node_modules、.git、构建产物全被打进镜像,既慢又臃肿,还可能把 .env 这种含密钥的文件泄露进镜像。每个项目都该有个 .dockerignore。
构建缓存原理(让构建飞起来)
Docker 构建镜像时,会从上到下逐条执行 Dockerfile,每条指令产出一层。关键机制是:
- 对每条指令,Docker 检查"输入"是否变化(基础镜像版本、COPY 的文件内容、RUN 的命令字符串)。
- 没变 → 直接复用缓存层,跳过执行(几乎瞬时)。
- 变了 → 这一层的缓存失效,后续所有层都重新执行(因为可能依赖前面的结果)。
所以指令顺序直接决定构建速度。不变的放前面,常变的放后面。最典型的反例:
- 错误:先
COPY . .再RUN npm install。改一行代码 → COPY 层变化 → npm install 重新跑,从 10 秒变 5 分钟。 - 正确:先
COPY package*.json再RUN npm install,最后COPY . .。改代码不影响依赖安装层,缓存命中,只重新 COPY 和构建。
这条规则理解了,你写的 Dockerfile 就比 80% 的人专业。下一个层次是多阶段构建——见最佳实践章。
← 上一篇 Docker 容器
下一篇 Docker 数据卷 →