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. 以为 CMDRUN 一样在构建时执行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 通用)