Docker 中 COPY 和 ADD 的选择
dockerfile COPY [--chown=:] <源路径>... <目标路径> COPY [--chown=:] ["<源路径>",... "<目标路径>"]
#### 典型场景
```dockerfile
# 复制单个文件
COPY app.jar /app/
# 复制整个目录(注意:目录本身不复制,复制的是其内容)
COPY ./src /app/src
# 使用通配符复制多个文件
COPY ./config/*.yaml /app/config/
# 设置文件所有者(需要镜像已存在该用户)
COPY --chown=myuser:mygroup ./scripts /home/myuser/scripts
ADD 的“智能”双刃剑
ADD 是较早引入的指令,它实现了 COPY 的全部功能,并附加了两个特性:
- 如果
<源>是一个本地 tar 归档文件(gzip、bzip2、xz 等压缩格式均可),则自动解压到目标目录。 - 如果
<源>是一个 URL,则下载该文件并放入镜像(但不会对下载的 tar 文件进行自动解压)。
正是这两个特性让 ADD 成为引诱与陷阱并存的存在。
自动解压:唯一合理使用场景
当你确实需要将一个 tar 包的内容直接展开在镜像中,并且不关心 tar 包本身时,ADD 可以省去 COPY + RUN tar 的步骤,让 Dockerfile 更简洁。
# 将一个预编译的软件包解压到 /opt
ADD software.tar.gz /opt/
执行后 /opt/ 下直接就是 software.tar.gz 里面的内容,而不是 software.tar.gz 文件本身。这是 ADD 的核心价值。
URL 下载:为什么必须避免
ADD https://example.com/file.tar.gz /tmp/ 会做两件事:下载文件,然后将其放在 /tmp/(注意:是原样放置,不会解压)。这听起来方便,但问题重重:
- 缓存失效难控制:
ADD通过检查 HTTPLast-Modified头判断文件是否变化。如果远程服务器不支持该头或不返回一致的时间戳,每次构建都会重新下载,导致后续层全部缓存失效。 - 带来不可预料的文件:你无法在 Dockerfile 中直接审计下载的内容。文件可能被替换,导致构建出一致性被破坏。
- 增加构建脆弱性:构建过程依赖外部网络状态,拉取失败则构建终止。
- 镜像层污染:下载的文件单独占据一层。更优的做法是使用
RUN组合curl或wget,在完成下载、验证后立即清理缓存和临时文件,将操作合并在同一层。
永远用 RUN 替代 ADD 进行远程下载。
# 推荐的下载方式:原子操作,单层完成,可清理
RUN curl -SL https://example.com/file.tar.gz -o /tmp/file.tar.gz \
&& tar -xzf /tmp/file.tar.gz -C /opt/ \
&& rm -f /tmp/file.tar.gz
对比混乱的 ADD 版本:
# 不推荐:ADD 下载后不解压,还需要额外步骤,且无清理
ADD https://example.com/file.tar.gz /tmp/
RUN tar -xzf /tmp/file.tar.gz -C /opt/ && rm /tmp/file.tar.gz
前者不仅更清晰,而且由于 curl 和清理在同一层,最终镜像里不会残留下载的临时文件。
决策流程图:不再纠结
遇到文件复制需求,按以下顺序快速决策:
- 只是想从构建上下文复制文件或目录?
- → 使用 COPY。
- 需要复制的文件恰好是一个本地 tar 包,而且你希望它被自动解压?
- → 使用 ADD。
- 文件来自远程 URL?
- → 使用 RUN 配合
wget或curl,并在同一层完成解压、安装和清理。
- → 使用 RUN 配合
- 需要从上一阶段(多阶段构建)复制成果?
- → 使用 COPY --from=stage,这是
COPY的扩展,干净且专为此目的设计。
- → 使用 COPY --from=stage,这是
这个流程可以帮你覆盖超过 99% 的实际构建场景。
常见误区澄清
- 误区一:ADD 会解压从 URL 下载的 tar 文件。
- 真相:不会。ADD 只会自动解压本地的 tar 文件。从 URL 下载时,永远只是存放原文件。
- 误区二:ADD 更高级,所以应该作为默认选择。
- 真相:COPY 才是为“复制”这一单一动作专门优化的指令,行为简单、缓存可预测,是 Docker 官方明确推荐的默认选择。
- 误区三:为了极简 Dockerfile,可以用 ADD 完成所有复制和解压缩。
- 真相:为了极致的可维护性和缓存效率,明确区分指令职责更重要。一个既下载又解压的 RUN 行可以比 ADD+额外步骤更干净。
深入构建缓存:为什么选择影响速度
Docker 基于层缓存加速构建。当某条指令的输入未发生变化时,可以直接复用之前构建的层。
- COPY 的缓存逻辑:比较构建上下文中文件的内容哈希、元数据等。如果完全相同,则命中缓存。非常稳定。
- ADD 用于本地文件时,缓存逻辑与 COPY 类似,但需要额外判断是否需要解压;用于 URL 时,依赖远程 Last-Modified 头,或者如果使用特定构建参数(如
--no-cache)则完全失效。
一个真实例子:你使用 ADD http://example.com/config.toml /app/ 拉取配置。第二天远程文件内容没变,但服务器版本更新导致 Last-Modified 变了,你的镜像层缓存立即失效,整个构建链都需要重跑。而如果用 RUN curl ... 并在下载前设置合理的缓存控制或使用固定版本号,你可以实现更可靠的重构。
安全维度:最小权限与可审计性
COPY 仅从构建上下文读取,来源完全受你控制。ADD 的 URL 能力却为供应链攻击打开了潜在窗口——如果远程服务器被攻破或你误用了不可信的 URL,注入的恶意文件将直接进入镜像。
更安全的 DevOps 实践是:将所有外部依赖的下载在早期阶段用 RUN 明确处理,校验哈希值或签名,然后通过多阶段构建用 COPY --from 将验证过的产物带入最终镜像。这样,每一字节的来源都是可追溯、可验证的。
多阶段构建中的黄金搭档
在多阶段构建中,COPY 和 ADD 的组合规则同样适用。从先前阶段复制文件时,唯一正确的选择是 COPY --from=<stage>。这保证了跨阶段复制的透明度和缓存有效性。
# 第一阶段:编译
FROM golang:1.21 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app/server
# 第二阶段:运行
FROM alpine:3.19
# 正确:从 builder 阶段复制,清晰无比
COPY --from=builder /app/server /usr/local/bin/server
CMD ["server"]
不要幻想使用 ADD 从先前阶段带来 tar 并解压——那是不可能且不必要的。
终极实践清单
- 默认拿起 COPY:任何文件、目录的单纯复制都用它。
- 解压本地 tar 才用 ADD:并明确在注释里说明为何必须用 ADD。
- 拒绝 ADD 的 URL 形式:永远用
RUN+ 下载工具替代,同时在同一层完成清理。 - 善用 .dockerignore:无论使用哪个指令,都要排除不必要的文件(如
.git、临时文件)以减少构建上下文大小,提升复制速度。 - 结合多阶段构建:用 COPY 在外层阶段优雅地组装最终镜像。
- 添加注释:如果你在团队中使用 ADD,注释解释原因,避免其他人“好心”改回 COPY。
# 使用 ADD 是因为需要直接解压本地 tar 包,避免额外的 RUN tar 层
ADD app-packed.tar.gz /opt/app/
COPY config.yaml /opt/app/