3. Dockerfile 基础指令归纳总结

写 Dockerfile 这件事,说难不难,说简单也不简单。我早期踩过的坑,多半出在「以为写对了,实际跑起来就崩」。Dockerfile 是镜像的蓝图,你画的每一行,最终都会变成容器的血肉。今天咱们就把最常用的指令挨个捋一遍,顺便聊聊我自己的血泪经验。

先记住一个原则:每个指令都会产生一个新层。层数越少,镜像越小,构建越快,缓存命中率越高。

3.1 FROM — 选对底子

每一份 Dockerfile 的起点都是 FROM。它指定基础镜像,说白了就是你的房子打地基。

FROM ubuntu:22.04
FROM node:18-alpine
FROM golang:1.21-bullseye

我个人习惯尽量用官方镜像的 slim 或 alpine 变体。ubuntu:22.04 有 80MB,ubuntu:22.04-slim 只有 30MB 左右。你想想看,一个项目里几十个服务,每个小 50MB,磁盘压力就小很多。

这里有个坑:FROM scratch 不是真的空镜像,它是个特殊标记,只有编译过的二进制文件才能跑。我曾经直接把 Python 脚本放进去,然后容器启动就报错 —— 你猜怎么着?scratch 里连 /bin/sh 都没有。

3.2 WORKDIR — 别到处乱逛

WORKDIR 设置工作目录。很多新手直接在根目录下 COPY

WORKDIR /app

嗯,这里要注意:WORKDIR 如果目录不存在,Docker 会自动创建。它后面的所有 RUN、CMD、ENTRYPOINT、COPY、ADD 都会以这个目录为起点。我建议每个项目都先写 WORKDIR /app,不然文件散落在各个目录,后期维护起来头大。

3.3 COPY 与 ADD — 选谁?

指令适用场景注意事项
COPY普通文件复制不会自动解压,简单安全
ADD需自动解压 tar.gz / 支持远程 URL行为隐晦,易导致缓存失效
COPY package.json /app/
COPY . .

# 不推荐这样用
ADD https://example.com/app.tar.gz /tmp/

我个人习惯:99% 情况用 COPY。ADD 的自动解压看起来很方便,但会让你搞不清层到底变了没有。而且远程 URL 下载的文件不会自动清理,镜像体积会无端变大。我曾经就因为这个,一个镜像从 200MB 涨到 500MB,排查半天才发现是 ADD 了一个 tar.gz 没删掉。

3.4 RUN — 干活的主力

RUN 在构建时执行命令。常见写法:

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        build-essential && \
    rm -rf /var/lib/apt/lists/*

注意这里我把安装命令和清理连在一起,用 && 串起来。为什么?因为 Docker 的缓存机制是按层来的。如果分开写:

RUN apt-get update
RUN apt-get install -y build-essential

那 apt-get update 那层会被缓存,后续就算改了源也没法重新执行,因为缓存命中了。所以一定要把 update、install、clean 放在同一个 RUN 里。

避坑指南:RUN 里执行 pip install 或 npm install 时,记得后面加上 --no-cache-dir 或 --production,不然开发依赖和缓存文件都会进镜像。

3.5 ENV — 环境变量

环境变量在 Dockerfile 里有两个作用:一是控制构建行为,二是运行时传递给容器。

ENV NODE_ENV=production
ENV PYTHONDONTWRITEBYTECODE=1
ENV MY_APP_VERSION=2.0.0

我建议把 ENV 写在一起,方便维护。但要注意,ENV 设置的值会持久化到最终镜像里。如果你只想要构建时用,用 ARG 更合适。

3.6 ARG — 构建参数

ARG VERSION=1.0.0
ARG BUILD_DATE

RUN echo "Building version $VERSION at $BUILD_DATE"

ARG 只在构建过程中生效,不会留在镜像里。我经常用它在 CI 里传递版本号。举个例子:

docker build --build-arg VERSION=2.1.0 -t myapp:2.1.0 .

这样镜像里没有多余信息,干净。

3.7 CMD 与 ENTRYPOINT — 启动时看你的

这俩经常被搞混。我简单概括:

  • CMD 提供默认命令,可以被 docker run 后面的参数覆盖。
  • ENTRYPOINT 固定入口,不容易被覆盖。
  • 两者可以同时用:ENTRYPOINT 指定可执行文件,CMD 提供默认参数。
# 示例:同时使用
ENTRYPOINT ["python"]
CMD ["app.py"]

# docker run myimage  ->  python app.py
# docker run myimage server.py  ->  python server.py

如果你想让容器像个可执行程序一样被调用,就用 ENTRYPOINT。我做微服务时,通常把二进制文件设为 ENTRYPOINT,参数留空,让用户自己传。

3.8 EXPOSE — 只是个文档

EXPOSE 8080 并不会真的打开端口。它的作用是告诉使用者:「这个容器内部监听 8080 端口」。真正的端口映射还是要靠 docker run -p 或 docker-compose 里的 ports。

我自己的习惯:每个暴露的端口都写清楚注释,比如 EXPOSE 8080 # HTTP API。不然看 Dockerfile 的人还得去查代码才知道哪个端口干什么用的。

3.9 USER — 别用 root

RUN groupadd -r app && useradd -r -g app app
USER app

安全性的基本要求。默认 Docker 容器以 root 运行,一旦被攻破,宿主也危险。我建议在 Dockerfile 末尾加一条 USER nobody 或者创建一个专用用户。这条规则我在生产环境里踩过一次,容器被挂载了宿主目录,攻击者直接写入了宿主文件系统……还好是测试系统。

3.10 VOLUME — 声明数据卷

VOLUME /data

用来标记容器里哪些目录需要持久化数据。但它只是声明,不会自动创建卷。实际挂载还是靠 -v 或 --mount。我一般写在 Dockerfile 里做文档说明,实际在 compose 里设置挂载。

3.11 HEALTHCHECK — 健康检查

Docker 1.12 之后支持内置健康检查。

HEALTHCHECK --interval=30s --timeout=3s \
  CMD curl -f http://localhost:8080/health || exit 1

写在 Dockerfile 里的好处是:任何基于这个镜像跑的容器都会自动继承健康检查,不用每次都在启动命令里加。我参与的一个团队曾经没写 HEALTHCHECK,结果容器死了两天没人发现……后来加上了,报警就及时了。

3.12 指令执行顺序实战建议

写 Dockerfile 时,指令顺序直接影响构建速度和缓存命中。我通常按这个顺序:

  1. FROM
  2. LABEL / ARG / ENV
  3. WORKDIR
  4. COPY 依赖声明文件(如 package.json、requirements.txt)
  5. RUN 安装依赖
  6. COPY 剩余源代码
  7. RUN 编译、构建
  8. USER
  9. EXPOSE
  10. HEALTHCHECK
  11. CMD / ENTRYPOINT

为什么先 copy 依赖文件再 copy 代码?因为依赖变更频率低,先 copy 它们可以利用缓存。如果你把代码和依赖一起 copy,每次改代码都得重新安装依赖,浪费时间。

核心思想:把「变化慢的」放前面,「变化快的」放后面。这样修改源代码时,依赖安装那层直接命中缓存,几秒就完成了。

3.13 一张图总结指令关系

下面这张 SVG 图把 Dockerfile 常用指令按照「构建阶段」和「运行阶段」做了分组,箭头表示执行顺序和数据流向。

Dockerfile 指令分组与执行顺序 构建阶段 运行阶段 FROM & ARG & ENV WORKDIR & LABEL COPY (依赖文件) RUN (安装依赖) COPY (源代码) RUN (编译 / 构建) USER EXPOSE & HEALTHCHECK VOLUME CMD / ENTRYPOINT 构建产物传递 灰色虚线箭头表示构建结果传递到运行阶段,实际执行顺序从上到下

嗯,这张图你一定要存下。每个指令所在的阶段决定了它什么时候被处理。构建阶段指令会在 docker build 时执行,运行阶段指令只在 docker run 时生效。把这两个概念分开,写 Dockerfile 就不容易乱了。