Dockerfile 里最常被写错的三个指令,大概就是 RUN、COPY 和 ADD。RUN 负责执行命令,COPY 和 ADD 负责搬运文件,看起来简单,实际写起来却有很多细节:RUN 写成 shell 形式还是 exec 形式?ADD 能干的 COPY 是不是都能干?为什么有时候缓存全部失效,构建慢得像全量重来?这篇文章把这三个指令一次讲透。

RUN 指令:shell 形式与 exec 形式的区别
RUN 指令用于在镜像构建过程中执行命令,并把结果提交为新的镜像层。它有两种写法,第一种是 shell 形式,直接跟命令字符串;第二种是 exec 形式,用 JSON 数组的方式传入命令和参数。
FROM ubuntu:22.04 # shell 形式 RUN apt-get update && apt-get install -y curl # exec 形式 RUN ["apt-get", "install", "-y", "vim"]
两者的核心区别在于:shell 形式的命令会被包装成 /bin/sh -c "你的命令" 来执行,所以可以使用 shell 的变量替换、管道、通配符等特性;而 exec 形式会直接调用系统调用执行指定程序,没有 shell 参与。这也意味着 exec 形式里写 RUN ["echo", "$HOME"] 是拿不到环境变量的,因为变量替换依赖 shell,而这里根本没有 shell。
那为什么很多场合仍然推荐 exec 形式?关键在于信号处理。容器中 PID 为 1 的进程如果是通过 shell 形式启动的,shell 不会把 SIGTERM 信号转发给子进程,导致 docker stop 时进程无法优雅退出,只能等超时后被 SIGKILL 强杀。虽然 RUN 只在构建阶段执行,不直接影响运行时进程,但同样的书写习惯延伸到 CMD 和 ENTRYPOINT 时就会踩坑,所以养成 exec 形式的习惯是有价值的。
另一个实用技巧是把多条命令用 && 串联在同一条 RUN 里。Dockerfile 的每条指令都会生成一个层,如果 apt-get update 和 apt-get install 分成两条 RUN,最终镜像里会保留 update 产生的缓存层,白白增加体积。合并写还能顺手清理缓存:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*注意 rm 必须和前面的安装命令放在同一个 RUN 里。分层文件系统里,后面一层的删除并不能真正抹掉前面一层的文件,只是生成了一个"删除标记",镜像总体积不变。
COPY 与 ADD:能用 COPY 就不要用 ADD
COPY 和 ADD 都能把文件放进镜像,功能高度重叠,官方建议是优先使用 COPY。原因在于 ADD 比 COPY 多了两个"隐式行为",这些行为在你不了解时容易造成意外。
第一个隐式行为是自动解压。如果源文件是一个本地压缩包(如 tar.gz、tar.bz2),ADD 会自动把它解压到目标目录:
FROM alpine:3.19 # 自动解压到 /app 目录下 ADD app.tar.gz /app/ # 而下面这条只是原样复制一个压缩文件 COPY app.tar.gz /app/app.tar.gz
如果你确实需要解压,ADD 这个特性很方便,省了一条 RUN tar 命令。但问题在于读 Dockerfile 的人不一定知道这个行为,看到 ADD 未必意识到文件会被解开。反过来,如果你只是想复制一个压缩包(比如后续在容器里运行时再解压),用 ADD 就会得到错误结果。
第二个隐式行为是支持远程 URL。ADD 可以直接写 ADD https://ipipp.com/file.tar.gz /tmp/ 这样的远程地址。但这个功能的实现很粗糙:Docker 会用 curl 或 wget 把文件下载下来,没有认证支持,没有超时控制,也不会对下载结果做校验,而且下载的文件权限是 600,常常还需要额外一条 RUN 去改权限。官方明确建议,需要下载远程文件时用 RUN 配合 curl 或 wget 自己处理,还能利用缓存控制:
RUN curl -fsSL https://ipipp.com/file.tar.gz -o /tmp/file.tar.gz \
&& tar -xzf /tmp/file.tar.gz -C /opt \
&& rm /tmp/file.tar.gz两条指令在复制时的另一个细节是目标路径的结尾斜杠。如果目标以 / 结尾,会被视为目录;如果结尾没有斜杠,会被视为文件名。写成 COPY config.ini /etc/app/ 和 COPY config.ini /etc/app 的结果完全不同,后者会把 app 当作文件名覆盖内容。
此外,COPY 还有一个 ADD 没有的能力:可以用 --from 参数从多阶段构建的 earlier stage 或者基础镜像中复制文件,这是多阶段构建的基础用法,Go、Java 项目里编译和运行分离几乎都靠它:
FROM golang:1.22 AS builder WORKDIR /src COPY . . RUN go build -o /out/server ./cmd/server FROM alpine:3.19 COPY --from=builder /out/server /usr/local/bin/server ENTRYPOINT ["server"]
利用缓存机制优化构建速度
Docker 构建时会逐条检查指令缓存。对于 RUN,只要命令文本没变,缓存就有效;对于 COPY 和 ADD,Docker 会校验源文件的校验和,文件内容变了缓存就失效。理解这条规则,就能通过调整指令顺序大幅加速构建。
最常见的反面教材是把 COPY 放在最前面:
COPY . /app RUN go mod download RUN go build -o /app/server .
这样写的后果是,只要项目里任何一个源码文件改动,第一层 COPY 缓存失效,后面所有 RUN 全部重跑,依赖下载也要从头来一遍。正确做法是先复制依赖清单,下载依赖,再复制全部源码:
WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o server .
改动业务代码后,前两层的缓存依然有效,只有最后的构建需要重新执行,构建时间可以从几分钟缩短到几十秒。Node.js 项目先复制 package.json、Python 项目先复制 requirements.txt,都是同样的思路。
配合 .dockerignore 文件一起使用效果更佳。把 .git、node_modules、日志文件等排除在构建上下文之外,既能减小上下文传输体积,又能避免无关文件变动导致 COPY 缓存意外失效。最后再配合多阶段构建,把编译工具链留在 builder 阶段,最终镜像里只放运行产物,RUN、COPY、ADD 三条指令各司其职,就能得到一个又快又小的生产级镜像。
总结
记住三条原则:RUN 优先用 exec 形式并合并相关命令,减少层和控制体积;复制文件一律首选 COPY,只有明确需要自动解压本地 tar 包时才用 ADD,远程下载交给 curl;把变动最频繁的内容放在 Dockerfile 靠后的位置,让缓存尽可能命中。写 Dockerfile 时多想一步,构建速度和镜像体积都会给你回报。
DockerfileRUN指令COPY和ADD区别修改时间:2026-09-13 18:56:49