第3章:Dockerfile编写——指令详解与最佳实践

Dockerfile 说白了就是一张制作镜像的菜谱。你按照步骤写,Docker 就照着步骤一层层把镜像叠起来。我一开始写 Dockerfile 的时候,总觉得把文件 COPY 进去、跑几个命令就完事了,结果镜像动不动就几个 G,构建还特别慢。后来踩的坑多了,才摸清楚每一条指令背后的门道。

这一章咱们就聚焦核心指令:FROM、RUN、COPY、CMD,再加上我个人觉得必须掌握的 ENTRYPOINT 和 WORKDIR。我会把每个指令的坑和经验一起聊,你跟着看下来,再写 Dockerfile 应该不会抓瞎了。

Dockerfile 核心指令体系 FROM · 基础镜像 RUN · 构建时命令 COPY / ADD · 文件 CMD · 默认命令 ENTRYPOINT · 入口 WORKDIR · 工作目录 最佳实践 · 多阶段构建 第一阶段 (build):FROM golang:1.21 → WORKDIR /app → COPY . → RUN go build 第二阶段 (runtime):FROM alpine:3.18 → COPY --from=0 /app/bin /app → CMD ["/app/bin"] 最终镜像只包含可执行文件,不含构建工具链,体积减少 80% 以上

指令详解:逐个拆解

FROM · 基础镜像

每个 Dockerfile 都必须以 FROM 开头。没有 FROM,你连地基都没有。

  • FROM 可以指定镜像名称和标签,比如 FROM node:18-alpine
  • 也可以用 AS 起别名,比如 FROM golang:1.21 AS builder,这是多阶段构建的起点

我记得刚开始用 Docker 时,直接 FROM ubuntu:latest,结果基础镜像 700MB,跑个 Hello World 都要半天。后来换用 alpine 系列的镜像,体积直接降到几十 MB。你想想看,基础镜像的大小决定了最终产物的下限,选一个小的基础镜像是最省事的最佳实践。

我的习惯: 优先选择官方镜像的 alpine 变体(如 node:18-alpine、python:3.12-slim)。如果对 glibc 有依赖,选 slim 版本也够了。

RUN · 构建时命令

RUN 是在镜像构建过程中执行的命令,每一条 RUN 都会创建一个新的镜像层。层叠多了,镜像就臃肿。

我个人习惯把同类操作合并到一条 RUN 里,比如安装依赖和清理缓存写在一起:

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        curl \
        git \
        build-essential && \
    rm -rf /var/lib/apt/lists/*

为什么会这样做?因为每一条 RUN 就是一层,如果分开写:

  • RUN apt-get update —— 缓存了 update 的结果
  • RUN apt-get install curl —— 又加一层
  • RUN rm -rf ... —— 再一层

你删除文件的那层,并不会真正减少前面层占用的空间。所以一定要在一层里同时做安装和清理。

避坑: 我遇到过有人写 RUN apt-get update 但不做 apt-get install,然后下一层再 install。这样 update 的结果虽然缓存了,但后期如果换了基础镜像标签,这个缓存层可能过期,导致安装失败。干脆合并在一起最稳妥。

COPY vs ADD · 文件复制

COPY 和 ADD 都能把本地文件复制到镜像里。区别在于 ADD 多了自动解压和远程 URL 下载的能力。但说实话,在日常构建中,我尽量只用 COPY。

指令 功能 推荐场景
COPY 复制本地文件到镜像 几乎所有常规复制场景
ADD 复制+自动解压 tar.gz / 支持远程 URL 极少使用,除非必须自动解压本地的压缩包

我之前见过一个项目用 ADD 下载外部 tar 包,结果每次构建都重新下载,缓存完全失效。如果只是复制代码和配置文件,COPY . /app 足矣,别给自己添乱。

CMD vs ENTRYPOINT · 容器启动命令

这两个很容易混淆,我也踩过坑。简单说:

  • CMD:指定容器的默认启动命令。可以覆盖,比如 docker run ... /bin/bash 就覆盖了 CMD
  • ENTRYPOINT:固定入口点。不容易被覆盖,除非用 --entrypoint 参数

最佳实践是:ENTRYPOINT 定死入口,CMD 提供默认参数。举个例子:

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["redis-server", "--appendonly", "yes"]

这样启动容器时,docker run my-redis 直接跑 redis-server。如果你想换参数,docker run my-redis --appendonly no,CMD 被覆盖,ENTRYPOINT 不受影响。这个模式,我建议你做通用脚本时都用上。

WORKDIR · 工作目录

WORKDIR 指定后续指令的工作路径。没有它,默认是在根目录 / 下操作。我之前看到有人每层都写 RUN cd /app && ...,其实一条 WORKDIR /app 就够了,后面的 RUN、COPY、CMD 都会在这个目录下执行。

WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .

这样做还有个好处:WORKDIR 会自动创建目录,不需要在前面加 mkdir -p。

最佳实践:写好 Dockerfile 的几条铁律

  1. 最小化镜像层数:合并 RUN,减少 COPY 次数。
  2. 利用构建缓存:把不常变动的文件(如 package.json)提前 COPY,利用 Docker 的层缓存加速构建。
  3. 使用 .dockerignore:排除 node_modules、.git、_pycache_ 等,减少上下文大小,构建更快。
  4. 多阶段构建:将编译环境和运行环境分离,最终产物只包含运行所需文件。
多阶段构建示例:
# 阶段1:编译
FROM golang:1.21 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server

# 阶段2:运行
FROM alpine:3.18
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]

最终镜像只有 alpine 3.18 加一个可执行文件,大约 20MB。而如果不用多阶段,带上 Go 工具链和源码,镜像体积会超过 800MB。

关于 .dockerignore

这个文件我每次写 Dockerfile 都会带一个。它就像一个黑名单,告诉 Docker 构建上下文里哪些文件不要传给守护进程。没有它,你本地 node_modules 有 100MB,Docker 也会全传过去,构建速度慢得离谱。

# .dockerignore 示例
node_modules
.git
*.md
.gitignore
Dockerfile
.dockerignore

你想想看,构建上下文的大小直接影响 docker build 的耗时。每次上传几百 MB 的没用文件,工程师的耐心就是被这些小事磨光的。

指令对比速查

指令 执行时机 可被覆盖 每层提交
FROM 构建开始 否 是
RUN 构建中 否 是
COPY / ADD 构建中 否 是
CMD 容器启动 是(docker run 参数) 否
ENTRYPOINT 容器启动 部分(--entrypoint) 否
WORKDIR 构建中 否 否(仅影响上下文)
关于层缓存的一点补充: 在 docker build 时,如果某层的指令和上下文都没有变化,Docker 会直接复用缓存层。所以把变化频繁的步骤(比如 COPY 源码)放在后面,把固定步骤(比如安装系统包)放在前面,能最大化缓存命中率。

写 Dockerfile 就像搭积木,每一条指令都是一块积木。搭得好,镜像又小又稳,构建还快。搭得不好,镜像臃肿、构建慢、跑起来还容易出问题。这些指令的用法,你写个三五次就熟了,但最佳实践的经验,往往是在一次次的构建失败后才真正记住的。嗯,今天就聊到这里,你有空可以拿自己手头的项目试一下多阶段构建,从 800MB 优化到 20MB 的那种畅快感,值得体验一次。