第3章:Dockerfile编写——指令详解与最佳实践
Dockerfile 说白了就是一张制作镜像的菜谱。你按照步骤写,Docker 就照着步骤一层层把镜像叠起来。我一开始写 Dockerfile 的时候,总觉得把文件 COPY 进去、跑几个命令就完事了,结果镜像动不动就几个 G,构建还特别慢。后来踩的坑多了,才摸清楚每一条指令背后的门道。
这一章咱们就聚焦核心指令:FROM、RUN、COPY、CMD,再加上我个人觉得必须掌握的 ENTRYPOINT 和 WORKDIR。我会把每个指令的坑和经验一起聊,你跟着看下来,再写 Dockerfile 应该不会抓瞎了。
指令详解:逐个拆解
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。你想想看,基础镜像的大小决定了最终产物的下限,选一个小的基础镜像是最省事的最佳实践。
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 ... —— 再一层
你删除文件的那层,并不会真正减少前面层占用的空间。所以一定要在一层里同时做安装和清理。
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 的几条铁律
- 最小化镜像层数:合并 RUN,减少 COPY 次数。
- 利用构建缓存:把不常变动的文件(如 package.json)提前 COPY,利用 Docker 的层缓存加速构建。
- 使用 .dockerignore:排除 node_modules、.git、_pycache_ 等,减少上下文大小,构建更快。
- 多阶段构建:将编译环境和运行环境分离,最终产物只包含运行所需文件。
# 阶段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 的那种畅快感,值得体验一次。