Docker 常用命令的 5 个坑,第四个我踩了两次

15 阅读5分钟

上周 code review,看到同事的 Dockerfile 里写着 ADD requirements.txt,我愣了一下——这哥们把 ADD 当 COPY 用了。再往下看,docker-compose.yml 里的 depends_on 写得信心满满,但服务启动顺序根本不对。

这些问题我当年全踩过,有的还踩了不止一次。今天把最常见的 5 个坑整理出来,希望你少走点弯路。

COPY 和 ADD,真不是一回事

很多人觉得 ADD 是 COPY 的"增强版",能复制文件还能解压 tar、下载远程 URL。听着挺方便,实际上是个坑。

# 错误写法:ADD 会隐式解压 tar 文件,行为不可控
ADD app.tar.gz /app/
# 你以为复制了个压缩包,其实它被解压了

# 正确写法:明确意图,用 COPY 就完事
COPY app.tar.gz /app/

ADD 的隐藏行为太多了。你传了个 tar 文件进去,它自动解压;你写了个 URL,它自动下载。这些"贴心"操作在 CI 环境里经常翻车。

# 错误写法:用 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 的时候经常忽略。

# 踩坑写法:每次改一行代码,npm install 都要重跑
FROM node:18-alpine
WORKDIR /app
COPY . .              # 任何文件变动都导致这一层缓存失效
RUN npm install        # 于是每次构建都要重新装依赖,慢到怀疑人生

正确的做法是把不常变的依赖文件先复制进去,让缓存尽量命中。

# 优化写法:先复制 package.json,利用缓存
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./   # 只有依赖文件变了才重新安装
RUN npm install                           # 这层缓存命中率极高
COPY . .                                  # 代码改动不影响上面的缓存

还有一个容易忽略的点:.dockerignore 没配。node_modules.gitdist 这些目录全被 COPY 进去了,不仅镜像臃肿,缓存也容易被误触发。

# .dockerignore 示例,建议每个项目都配一个
node_modules
.git
dist
*.log

CMD 和 ENTRYPOINT,我踩了两次

这个坑我必须说说,因为它太隐蔽了。

第一次踩坑是写了个 CMD 的 shell 格式,结果容器启动后信号传递出了问题,docker stop 要等 10 秒才强杀。

# 踩坑写法:shell 格式,CMD 会被 /bin/sh -c 包一层
CMD node server.js
# 问题:进程 PID 不是 1,信号传不到 node 进程,优雅停机直接废了

# 正确写法:exec 格式,进程直接是 PID 1
CMD ["node", "server.js"]

第二次踩坑更离谱。我在基础镜像里写了 ENTRYPOINT,然后在业务镜像里用 CMD 传参数,结果参数怎么都传不过去。

# 基础镜像 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 格式,不然参数传递会一脸懵。

# 推荐写法:两个都用 exec 格式,行为清晰
ENTRYPOINT ["python", "app.py"]
CMD ["--port", "8080"]
# docker run 时传的参数会覆盖 CMD,但不会覆盖 ENTRYPOINT

docker-compose 的 depends_on,不等服务就绪

这个坑我见身边至少三个人踩过。depends_on 只保证容器启动顺序,不等服务真正 ready。

# 踩坑写法:以为 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

# 方案一:加健康检查,让 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 次才判定不健康

如果不想配健康检查,还有个土办法——在应用启动脚本里加重试逻辑。虽然不够优雅,但胜在简单粗暴。

# 方案二:应用侧重试(适合小项目快速搞定)
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 的坑?评论区聊聊。