04 · Dockerfile(Dockerfile)

📅 预计 60 分钟 | ⭐ = 重要知识点 | 📌 中英术语见文末
🛠 本讲示例基于 Linux 环境,命令可直接运行


4.0 先给直觉:做镜像的"菜谱"

前两讲我们直接 pull 现成镜像。那镜像怎么来的?答案是用 Dockerfile 做出来的。

把 Dockerfile 想成一张菜谱:每一步都是一条指令(步骤),按从上到下的顺序执行,一步步把材料加工好,最后"烤"出一个镜像。别人拿到你的菜谱,在任意机器上都能做出一样的菜——这就是"构建一次,到处运行"。

Dockerfile(菜谱)
     ↓ docker build -t 镜像名 .
镜像(成品)
     ↓ docker run
容器(上桌的那一份)

💡 记忆口诀:Dockerfile 从 FROM 起锅,每行指令是一步,RUN 在"制作时"执行,CMD/ENTRYPOINT 决定"上桌时"干什么。


4.1 常用指令 ⭐

FROM:起锅的基础镜像

Dockerfile 必须以 FROM 开头,它指定本镜像基于哪个基础镜像:

FROM node:18-alpine
FROM python:3.11-slim
FROM ubuntu:22.04

为什么必须第一行?因为镜像的每一层都叠在上一层之上,没有地基就无从建楼。基础镜像选 alpine(超小 Linux 发行版)能显著减小体积,生产常用。

RUN:制作时执行的命令

RUN apt-get update && apt-get install -y curl

RUN 在构建镜像时执行,其效果被固化进镜像层。注意两点:

  • 多个命令用 && 连成一条,避免每个命令各生成一层、镜像变臃肿。
  • -y 之类的非交互参数记得带上,否则构建时可能卡在交互确认。

COPY 与 ADD:把文件拷进镜像

COPY package.json /app/package.json
ADD app.tar.gz /app/          # ADD 额外支持 URL 和自动解压
  • COPY 只做"复制",语义单纯,优先用它。
  • ADD 多了自动解压压缩包、支持远程 URL 的能力,用不到这些能力时,别用它。

WORKDIR:设置工作目录

WORKDIR /app

之后的 RUN/COPY/CMD 都会在这个目录下执行。反复 cd 不如直接 WORKDIR,它还会自动创建不存在的目录。

ENV 与 ARG:注入变量

ENV NODE_ENV=production    # 环境变量,运行时容器里也生效
ARG VERSION=1.0            # 构建参数,只在构建时有效
  • ENV 会进入最终镜像,容器运行时可以用。
  • ARG 只在构建过程有效,构建时用 docker build --build-arg VERSION=1.1 覆盖。

EXPOSE:声明端口(只是文档!)

EXPOSE 3000

EXPOSE 只是告诉别人"我的服务监听 3000 端口",它不会真正映射端口。真正的映射由运行时 docker run -p 3000:3000 或 -P 完成。

USER:切换运行用户

USER node

出于安全,生产镜像避免用 root 跑应用,改用普通用户(第 6 讲展开)。

构建上下文与 .dockerignore ⭐

docker build -t myapp:1.0 . 最后的 . 是构建上下文(build context)目录。Docker 会把这个目录下的文件打包发给守护进程,Dockerfile 里的 COPY . . 就是从这里取文件的。

上下文目录不该装无关文件——node_modules、.git、缓存动不动几百 MB,全打包进去又慢又占空间。用 .dockerignore 文件排除(语法类似 .gitignore):

node_modules
.git
*.log
.env

写一份 .dockerignore,构建上下文立刻瘦下来,COPY 也不会误把密钥拷进镜像。

一个完整示例 ⭐

FROM node:18-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

构建并运行:

docker build -t myapp:1.0 .      # . 是构建上下文目录
docker run -d -p 3000:3000 myapp:1.0

构建时每执行一条指令会生成一层,终端会显示 Step 1/7 : FROM node:18-alpine 之类的步骤编号。缓存命中的步骤会显示 Using cache——这正是"变化少的指令放前面"的收益来源。

⚠️ 常见错误

  1. FROM 不在第一行:基础镜像必须在最开始指定,前面有其它指令会报错。
  2. 每条 RUN 各写一行:产生大量无用层、镜像膨胀,用 && 合并。
  3. 把 EXPOSE 当端口映射:它只是声明,真正对外开放要靠 docker run -p。

4.2 CMD 与 ENTRYPOINT:上桌时干什么 ⭐

两种写法:exec 形式 vs shell 形式

CMD ["node", "index.js"]    # exec 形式(数组),推荐
CMD node index.js           # shell 形式(字符串),会自动包一层 /bin/sh -c
ENTRYPOINT ["docker-entrypoint.sh"]   # 同样两种写法

ENTRYPOINT + CMD 的经典组合:先初始化再启动

真实场景里,容器启动前常要做初始化(等数据库、生成配置、替换模板),这套逻辑写进一个启动脚本,用 ENTRYPOINT 固定,CMD 给默认参数:

COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "index.js"]

脚本里做初始化,最后 exec 启动真正的进程,保证信号能正确传给主进程。这种"入口脚本 + 默认命令"模式在官方镜像里非常普遍(如 nginx、MySQL 镜像都这么干)。

核心区别:能不能被覆盖

  • CMD:提供默认启动命令,运行时可以被 docker run 后面的参数覆盖。
  • ENTRYPOINT:固定入口,docker run 后面的参数会拼在它后面,而不是替换它。

最经典的配合是:ENTRYPOINT 定"谁来做",CMD 定"默认做什么":

ENTRYPOINT ["nginx"]
CMD ["-g", "daemon off;"]

运行时 docker run mynginx -h,实际执行的是 nginx -h(-h 追加到了 ENTRYPOINT 后面,CMD 被忽略)。

再看一个反例,理解"CMD 可被覆盖":

CMD ["node", "index.js"]
# docker run myapp npm test   → 实际执行 npm test,CMD 被覆盖

⚠️ 常见错误

  1. 两种形式混着写:CMD ["node index.js"] 会被当成单个参数,找不到 "node index.js" 这个命令。exec 形式必须拆成 ["node", "index.js"]。
  2. 以为 CMD 和 RUN 一样在构建时执行:RUN 在构建时跑,CMD 是容器启动时跑,时机完全不同。
  3. 需要固定入口却用 CMD:如果入口必须不可覆盖,用 ENTRYPOINT;需要允许灵活追加参数,用 CMD。

4.3 多阶段构建:瘦身利器 ⭐

为什么需要

很多应用"编译用的工具链"非常重。比如 Go 编译需要完整的 Go 工具链(GB 级),但编译出来的二进制在运行时只需要一个极小的系统。如果只用一个阶段,这些工具链全会被塞进镜像。

多阶段构建:在 Dockerfile 里写多个 FROM,前面的阶段只用来"加工",最后只把产物拷进一个干净的小基础镜像。

示例:Go 程序

# 阶段一:builder,负责编译
FROM golang:1.21 AS builder
WORKDIR /src
COPY . .
RUN go build -o app main.go

# 阶段二:final,只要产物
FROM alpine:3.19
COPY --from=builder /src/app /app
CMD ["/app"]

关键点:

  • AS builder 给第一阶段起名。
  • COPY --from=builder /src/app /app:从 builder 阶段把编译产物拷过来。
  • 最终镜像只包含 alpine + 一个二进制,体积可能从 800MB 降到 20MB 左右。

好处

  • 镜像小:传输快、启动快、占用磁盘少。
  • 更安全:不再包含编译器、源码、调试信息等无关内容,攻击面变小。
  • 层数少:最终镜像只有两三层。

Python 版本示例

脚本语言同样适用多阶段。下面把"装依赖 → 跑测试 → 只拷依赖产物"分成两段:

# 阶段一:装依赖并验证
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --prefix=/install -r requirements.txt

# 阶段二:运行时镜像,只保留已安装的依赖
FROM python:3.11-slim
COPY --from=builder /install /usr/local
COPY app.py .
CMD ["python", "app.py"]

依赖被装进 /install,最终镜像复制过去即可,构建阶段的源码和中间文件一概不带。

构建优化小贴士

COPY package.json ./
RUN npm install        # 先拷依赖清单再装依赖
COPY . .               # 最后拷全部源码

把变化少的指令放前面,能充分利用层缓存:只要 package.json 没变,npm install 那一层就命中缓存,重建秒级完成。配合 .dockerignore 排除 node_modules、.git 等,避免把无关文件拷进上下文。

⚠️ 常见错误

  1. 忘了 AS 命名或 --from 写错阶段名:COPY --from=xxx 找不到阶段会直接失败。
  2. 多阶段只写不用:写了两个阶段却没 COPY --from,等于白写,两个阶段都会进最终镜像。
  3. 把 node_modules 拷进上下文:没有 .dockerignore,构建上下文可能数百 MB,构建又慢又大。

📌 双语术语表(本讲)

中文 English 记忆点
基础镜像 base image FROM 指定
构建上下文 build context docker build 的 .
工作目录 workdir 之后指令的默认目录
环境变量 environment ENV 运行时生效
构建参数 build arg ARG 构建时生效
暴露端口 expose 声明端口,非映射
启动命令 cmd 可被覆盖的默认命令
入口命令 entrypoint 固定入口,追加参数
多阶段构建 multi-stage build 产物瘦身
层缓存 layer cache 变化少的指令放前面
构建上下文 build context docker build 的 .
忽略文件 .dockerignore 缩小上下文、防泄密
入口脚本 entrypoint script 初始化后 exec 主进程

⭐ 本讲考点清单

  1. Dockerfile 必须以 FROM 开头
  2. RUN 构建时执行,CMD 容器启动时执行
  3. COPY vs ADD:COPY 语义单纯优先;ADD 自动解压
  4. WORKDIR 设置工作目录,自动创建
  5. ENV 进镜像运行时可用;ARG 仅构建时有效
  6. EXPOSE 只是声明,-p/-P 才是映射
  7. CMD 可被覆盖;ENTRYPOINT 固定入口、参数追加
  8. exec 形式 ["node", "index.js"] vs shell 形式
  9. 多阶段构建:AS 命名 → COPY --from 拷产物 → 最终镜像只含产物
  10. 变化少的指令放前面利用层缓存;.dockerignore 减小上下文
  11. 构建上下文 = docker build 的 .;.dockerignore 排除 node_modules、.git、.env
  12. ENTRYPOINT + CMD 组合:入口脚本做初始化,CMD 给默认参数
  13. 多阶段构建的 COPY --from 可拷任意中间阶段产物(Go / Python 通用)