3. Dockerfile编写入门

Dockerfile 说白了就是一份构建说明书。你告诉 Docker 用哪个基础镜像、执行什么命令、复制哪些文件、最后启动啥程序。我刚开始接触时觉得这玩意儿挺简单,结果踩了不少坑——镜像体积动不动上 GB,构建时间能把人急死。后来慢慢摸清了门道,今天跟你聊聊核心指令和优化技巧。

3.1 FROM —— 一切从这里开始

每个 Dockerfile 必须从 FROM 开始。它指定了基础镜像。比如:

FROM ubuntu:22.04

我建议尽量用官方镜像,alpine 系列体积小,适合生产。但注意 alpine 的 libc 是 musl,有些依赖 glibc 的软件可能跑不起来。我曾经因为这个问题折腾了一下午,最后换成 debian-slim 才搞定。

你可以用 AS 给阶段起别名,为多阶段构建做准备:

FROM golang:1.21 AS builder
小技巧:固定镜像版本标签(如 :22.04 而非 :latest),避免构建结果因镜像更新而“飘移”。我习惯在基础镜像上再加一行注释记录选择原因,方便后来人理解。

3.2 RUN —— 真正干活的指令

RUN 在构建时执行命令,每次执行都会生成一个新层。你想想看,如果每行一个 RUN,镜像里就会多好几层,体积和构建时间都上去了。

我的习惯是把同类命令用 && 串起来,减少层数。比如安装系统依赖:

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

这里我用了 --no-install-recommends 避免装无关包,最后清理 apt 缓存。注意每次 apt-get update 后必须紧跟着 install 和 clean,否则 update 的缓存会被锁在层里,后面再 install 会报错。我曾经见到有人把 update 和 install 分成两个 RUN,结果镜像多出几百兆无用的缓存文件。

别把 RUN rm -rf /var/lib/apt/lists/* 单独写成一行——它在另一层,前面的 update 层早把缓存存下来了,删不掉。必须在同一个 RUN 内完成 update、install、clean。

3.3 COPY 与 ADD —— 把文件塞进镜像

COPY 是最常用的文件复制指令,语法简单:

COPY requirements.txt /app/
COPY . /app/

它会保留文件权限和软链接。我个人的习惯:能用 COPY 绝不用 ADD。ADD 除了复制还能自动解压 tar 包、从 URL 下载文件,但行为不够透明。有一次我 ADD 了一个压缩包,结果它自动解压了,我原本期望的是一个压缩文件直接放到容器里……调试了好久才意识到是 ADD 的“黑魔法”。

如果你需要解压 tar,用 ADD 反而方便:

ADD app.tar.gz /app/

但手动用 RUN 配合 curl 和 tar 更可控:

RUN curl -L https://example.com/pkg.tar.gz | tar xz -C /app

这样层数、缓存、网络依赖都一目了然。

3.4 CMD 与 ENTRYPOINT —— 容器启动时跑什么

这两个指令最容易混淆。简单说:

  • CMD 提供默认命令和参数,可以被 docker run 后面的命令覆盖。
  • ENTRYPOINT 定义容器入口,通常固定,不容易被覆盖。

它们可以组合使用。我常用的模式:ENTRYPOINT 指定可执行文件,CMD 提供默认参数。这样用户可以通过加参数覆盖 CMD,但入口程序不变。

用法docker run 后面的命令实际执行
CMD ["python", "app.py"](无)python app.py
CMD ["python", "app.py"]python other.pypython other.py(覆盖)
ENTRYPOINT ["python"]
CMD ["app.py"]
other.pypython other.py(参数覆盖 CMD)
ENTRYPOINT ["python", "app.py"]--helppython app.py --help(附加到入口后)

我曾在项目中用 ENTRYPOINT 跑一个健康检查脚本,然后 CMD 设为主服务入口。这样容器启动时先跑健康检查,再启动主程序,结构清晰。不过要小心:如果 ENTRYPOINT 写的是 shell 形式(如 ENTRYPOINT python app.py),PID 1 会是 /bin/sh -c,信号转发会有问题。我建议始终使用 exec 形式:

ENTRYPOINT ["python", "app.py"]
CMD []
经验之谈:用 exec 形式的数组语法,容器能直接接收 SIGTERM 等信号,优雅关闭进程。我曾经因为用了 shell 形式,docker stop 等了 10 秒才强杀进程,数据都没来得及刷盘。

3.5 构建优化 —— 让镜像又小又快

优化不是玄学,有几点我每次构建都会留意:

  1. 合层:所有 RUN 命令能合并就合并,特别是包管理和清理操作。
  2. 利用构建缓存:把不常变化的指令(如安装系统包)写在前面,把频繁变化的(如复制源代码)写在后面。这样缓存命中率高。
  3. .dockerignore:和 .gitignore 类似,排除 node_modules、.git、日志等不需要的文件,避免把大文件打包进上下文。
  4. 多阶段构建:用临时镜像编译,然后把产物复制到小基础镜像里,最终镜像只包含运行时依赖。比如 Go 项目:
FROM golang:1.21 AS builder
WORKDIR /src
COPY go.mod go.sum .
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o app .

FROM alpine:3.19
COPY --from=builder /src/app /app
CMD ["/app"]

最终镜像可能只有 20MB,比直接用 golang 镜像做出来的 1GB 小太多了。我第一次用多阶段构建时,同事看到镜像大小都惊了。

docker build --no-cache 可以强制重新构建,调试时偶尔用。但线上 CI 建议开启缓存,加速构建。我习惯把 COPY go.mod go.sum 和 RUN go mod download 放在代码复制之前,因为依赖文件变化频率远低于源代码,能省下不少下载时间。

3.6 指令关系速览

下面这张图把 Dockerfile 核心指令和构建流程串了起来。每个框代表一个阶段,箭头表示执行顺序,虚线框是优化要点。

Dockerfile 构建流程与优化 FROM 基础镜像 RUN 安装系统依赖 & 清理缓存 COPY 应用代码 RUN 编译 / 安装依赖 CMD / ENTRYPOINT 优化要点 • 合并 RUN 减少层 • 利用缓存排序指令 • 多阶段构建 每个指令生成一个 不可变层,Docker 会缓存每一层

箭头方向就是构建顺序。注意看左边的优化框:合并 RUN、利用缓存、多阶段构建,这些是让镜像又小又快的关键。右边的说明框提醒你每个指令都会生成一个不可变层,Docker 会缓存这些层用于加速。

3.7 避坑清单

  • 不要在 RUN 里用 cd 切换目录——每个 RUN 都开新 shell,cd 无效。应该用 WORKDIR 设置工作目录。
  • 敏感信息不要硬编码在镜像里——构建时用 ARG,运行时用环境变量或 Docker Secrets。我曾经见过有人把数据库密码直接写在 Dockerfile 里,差点酿成事故。
  • 注意文件权限——COPY 来的文件默认是 root 属主,如果后面用非 root 用户运行,可能没权限读。我习惯在最后一层用 RUN chown -R appuser:appuser /app 调整。
特别注意:docker build 会把当前目录(构建上下文)发送给 Docker daemon,如果你的目录下有几 GB 的数据(例如 node_modules、数据集),构建会慢到崩溃。一定要配好 .dockerignore!

好了,Dockerfile 的核心指令和优化方法大概就这些。自己动手写几个小镜像,跑一遍 docker history 看看每层的变化,你会对层缓存有更直观的感受。