上周 code review,看到同事的 Dockerfile 里写着 ADD requirements.txt,我愣了一下------这哥们把 ADD 当 COPY 用了。再往下看,docker-compose.yml 里的 depends_on 写得信心满满,但服务启动顺序根本不对。
这些问题我当年全踩过,有的还踩了不止一次。今天把最常见的 5 个坑整理出来,希望你少走点弯路。
COPY 和 ADD,真不是一回事
很多人觉得 ADD 是 COPY 的"增强版",能复制文件还能解压 tar、下载远程 URL。听着挺方便,实际上是个坑。
dockerfile
# 错误写法:ADD 会隐式解压 tar 文件,行为不可控
ADD app.tar.gz /app/
# 你以为复制了个压缩包,其实它被解压了
# 正确写法:明确意图,用 COPY 就完事
COPY app.tar.gz /app/
ADD 的隐藏行为太多了。你传了个 tar 文件进去,它自动解压;你写了个 URL,它自动下载。这些"贴心"操作在 CI 环境里经常翻车。
dockerfile
# 错误写法:用 ADD 下载远程文件,多了一层不可控的网络依赖
ADD https://example.com/config.json /app/config.json
# 正确写法:用 RUN + curl,行为透明,还能加校验
RUN curl -fSL https://example.com/config.json -o /app/config.json
Docker 官方文档的建议很直接:首选 COPY。只有在你明确需要自动解压 tar 的时候才用 ADD。记住这条就够了。
构建缓存的顺序,别写反了
Docker 构建是一层一层来的,每一层都有缓存。只要上面某一层变了,后面的层全部重新构建。这个机制很多人知道,但写 Dockerfile 的时候经常忽略。
dockerfile
# 踩坑写法:每次改一行代码,npm install 都要重跑
FROM node:18-alpine
WORKDIR /app
COPY . . # 任何文件变动都导致这一层缓存失效
RUN npm install # 于是每次构建都要重新装依赖,慢到怀疑人生
正确的做法是把不常变的依赖文件先复制进去,让缓存尽量命中。
dockerfile
# 优化写法:先复制 package.json,利用缓存
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # 只有依赖文件变了才重新安装
RUN npm install # 这层缓存命中率极高
COPY . . # 代码改动不影响上面的缓存
还有一个容易忽略的点:.dockerignore 没配。node_modules、.git、dist 这些目录全被 COPY 进去了,不仅镜像臃肿,缓存也容易被误触发。
text
# .dockerignore 示例,建议每个项目都配一个
node_modules
.git
dist
*.log
CMD 和 ENTRYPOINT,我踩了两次
这个坑我必须说说,因为它太隐蔽了。
第一次踩坑是写了个 CMD 的 shell 格式,结果容器启动后信号传递出了问题,docker stop 要等 10 秒才强杀。
dockerfile
# 踩坑写法:shell 格式,CMD 会被 /bin/sh -c 包一层
CMD node server.js
# 问题:进程 PID 不是 1,信号传不到 node 进程,优雅停机直接废了
# 正确写法:exec 格式,进程直接是 PID 1
CMD ["node", "server.js"]
第二次踩坑更离谱。我在基础镜像里写了 ENTRYPOINT,然后在业务镜像里用 CMD 传参数,结果参数怎么都传不过去。
dockerfile
# 基础镜像 Dockerfile.base
ENTRYPOINT ["python", "app.py"]
# CMD 被 ENTRYPOINT 的 exec 格式"接管"了,下面的 CMD 只是默认参数
# 业务镜像 Dockerfile
FROM mybase:latest
CMD ["--port", "8080"] # 这行其实是给 ENTRYPOINT 传参数,不是独立命令
# 实际执行的是:python app.py --port 8080
搞明白两者的关系很重要:ENTRYPOINT 定义容器的"主命令",CMD 提供默认参数。如果你不需要自定义容器行为,只写 CMD 就行。两个一起用的时候,一定要用 exec 格式,不然参数传递会一脸懵。
dockerfile
# 推荐写法:两个都用 exec 格式,行为清晰
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]
# docker run 时传的参数会覆盖 CMD,但不会覆盖 ENTRYPOINT
docker-compose 的 depends_on,不等服务就绪
这个坑我见身边至少三个人踩过。depends_on 只保证容器启动顺序,不等服务真正 ready。
yaml
# 踩坑写法:以为 depends_on 会等 MySQL 就绪
services:
web:
build: .
depends_on:
- db # 只保证 db 容器先启动,不等 MySQL 准备好接受连接
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: secret
web 容器启动的时候,MySQL 可能还在初始化。你的应用一上来就连数据库,直接报 Connection refused。
yaml
# 方案一:加健康检查,让 depends_on 等 condition: service_healthy
services:
web:
build: .
depends_on:
db:
condition: service_healthy # 等 db 健康检查通过才启动
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: secret
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] # MySQL 自带的探活命令
interval: 5s # 每 5 秒检查一次
timeout: 3s # 超时 3 秒算失败
retries: 10 # 连续失败 10 次才判定不健康
如果不想配健康检查,还有个土办法------在应用启动脚本里加重试逻辑。虽然不够优雅,但胜在简单粗暴。
yaml
# 方案二:应用侧重试(适合小项目快速搞定)
services:
web:
build: .
command: >
sh -c "
for i in $$(seq 1 30); do
nc -z db 3306 && break; # 探测 MySQL 端口是否通
echo 'Waiting for db...';
sleep 2;
done;
node server.js # 数据库就绪后再启动应用
"
depends_on:
- db
最后
五个坑简单总结一下:
- COPY 和 ADD 别混用,绝大多数场景用 COPY 就够
- Dockerfile 里把不常变的层放前面,充分利用构建缓存
- 别忘了配
.dockerignore,不然缓存和镜像体积都受影响 - CMD 和 ENTRYPOINT 优先用 exec 格式,别用 shell 格式
- docker-compose 的
depends_on不等服务就绪,要加健康检查或重试逻辑
这些都是实战里踩过才记住的,希望对你有用。你踩过哪些 Docker 的坑?评论区聊聊。