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 里。
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 时,指令顺序直接影响构建速度和缓存命中。我通常按这个顺序:
FROMLABEL/ARG/ENVWORKDIRCOPY依赖声明文件(如 package.json、requirements.txt)RUN安装依赖COPY剩余源代码RUN编译、构建USEREXPOSEHEALTHCHECKCMD/ENTRYPOINT
为什么先 copy 依赖文件再 copy 代码?因为依赖变更频率低,先 copy 它们可以利用缓存。如果你把代码和依赖一起 copy,每次改代码都得重新安装依赖,浪费时间。
3.13 一张图总结指令关系
下面这张 SVG 图把 Dockerfile 常用指令按照「构建阶段」和「运行阶段」做了分组,箭头表示执行顺序和数据流向。
嗯,这张图你一定要存下。每个指令所在的阶段决定了它什么时候被处理。构建阶段指令会在 docker build 时执行,运行阶段指令只在 docker run 时生效。把这两个概念分开,写 Dockerfile 就不容易乱了。