第3章:Dockerfile入门

说实话,Dockerfile 这东西,看着像一份菜谱。你写清楚步骤,Docker 照着做,一个镜像就出来了。刚开始接触的时候,我觉得不就是几条命令嘛,有啥难的?后来被坑了几次才明白——一个小小的换行、一个 COPY 路径写错,镜像就能从 200MB 膨胀到 2GB。嗯,细节决定成败。

为什么需要 Dockerfile?

你想想看,手动拉个基础镜像,进容器装软件,再 commit 成新镜像……这套流程是不是很熟悉?我早期就是这么干的。但问题来了:三个月后你还能记得当时装了什么吗?别人接手你的项目,怎么复现这个环境?

Dockerfile 解决的就是这个痛点。把构建步骤写成文本,版本管理、自动化、复现,一次性搞定。

一句话说透:Dockerfile = 可重复的镜像构建脚本。

Dockerfile 基础语法

我们从一个最简单的例子开始。先别管那么多花哨指令,先跑通再说。

# 这是我的第一个 Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y nginx
COPY index.html /var/www/html/
CMD ["nginx", "-g", "daemon off;"]

这个文件干了三件事:拉 Ubuntu 22.04 当基础层,装 Nginx,把本地 index.html 拷进去,最后启动 Nginx。够直白吧?

一个小习惯:我习惯在 Dockerfile 开头加个注释,说明这个镜像的用途和维护者。虽然不强制,但团队协作时很管用。

四大核心指令详解

Dockerfile 的指令不少,但日常用得最多的也就这几个。咱们一个一个来过。

1. FROM —— 选对基础镜像

所有 Dockerfile 必须以 FROM 开头。没有基础镜像,就没有构建的起点。

FROM alpine:3.19           # 推荐,小且安全
FROM python:3.12-slim      # Python 项目常用
FROM node:20-alpine        # Node.js 项目常用

我个人强烈推荐用 Alpine 或 Slim 版本。为什么?镜像小啊!alpine 版的 python 才 90MB,完整版要 900MB。我在项目里见过有人拉了个 1.5GB 的 Ubuntu 基础镜像,跑个简单的 Flask 应用……你琢磨琢磨,光传输就要等多久?

避坑指南:我曾经在生产环境用了 ubuntu:latest,结果某天 latest 更新了,构建出来的镜像运行时符号链接全炸了。从此以后,我坚持用具体版本号,绝不写 latest。

2. RUN —— 在构建中执行命令

RUN 在构建阶段执行,结果会固化到镜像层里。注意,每写一个 RUN,就多一层。

RUN apt-get update && apt-get install -y \
    python3 \
    python3-pip \
    curl

# 不推荐这样写:
RUN apt-get update
RUN apt-get install -y python3
RUN apt-get install -y curl

看到了吗?第二段把三个 RUN 拆开了,镜像会多出两层,而且每层还缓存了临时文件。我建议所有安装操作合并到一个 RUN 里,结尾加个清理:

RUN apt-get update && apt-get install -y \
    curl \
    vim \
 && rm -rf /var/lib/apt/lists/*

这样既减少层数,又清理了 apt 缓存,镜像能瘦不少。

3. COPY —— 把文件送进镜像

COPY 把宿主机上的文件或目录复制到镜像里。路径要搞清楚:第一个是宿主机路径(相对于 Dockerfile 位置),第二个是容器内路径。

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

这里有个坑。你写 COPY . /app/,会把 Dockerfile 同级目录下所有东西都拷进去。如果你目录里有 node_modules、.git 这些大文件,镜像瞬间膨胀。怎么办?用 .dockerignore 文件。

# .dockerignore
node_modules
.git
*.log
__pycache__

我曾经踩过的坑:一次忘记写 .dockerignore,把整个项目的 node_modules(几百MB)塞进了镜像。构建花了一分钟,传输花了三分钟,同事问我这镜像怎么这么大……我当时真想找个地缝钻进去。

4. CMD —— 容器跑起来后干啥

CMD 指定容器启动时执行的命令。注意,一个 Dockerfile 只能有一个 CMD 生效。如果写了多个,只有最后一个会运行。

# Shell 格式(会自动调用 /bin/sh -c)
CMD nginx -g "daemon off;"

# Exec 格式(推荐,不会产生 shell 进程)
CMD ["nginx", "-g", "daemon off;"]

我习惯用 Exec 格式。Shell 格式会多一个进程,而且信号传递有问题——你 docker stop 的时候,Shell 进程可能不响应 SIGTERM,容器得等超时才被强制杀掉。用 Exec 格式就没这毛病。

构建镜像

写完了 Dockerfile,就该构建了。命令很简单:

docker build -t my-app:v1 .

这里 -t 是指定标签,最后的点代表构建上下文(当前目录)。Docker 会把上下文打包发给守护进程,所以尽量把小目录作为上下文,别把整个家目录传进去。

分层构建与缓存机制

Docker 镜像是分层的,每行指令生成一层。构建时,Docker 会检查缓存:如果指令没变,前面层的缓存就能用。这机制能大幅加速重复构建。

但要注意 指令顺序影响缓存命中率。我建议把变化频率低的指令放前面,变化频繁的放后面:

FROM node:20-alpine

# 先复制依赖文件 —— 不容易变
COPY package*.json ./
RUN npm install

# 再复制源代码 —— 经常变
COPY . .
CMD ["node", "app.js"]

为什么这样排?你想想,如果先把所有源代码 COPY 进去,然后才 npm install,那每次改代码都要重新 npm install。把依赖安装提前,依赖没变就用缓存,省多少时间啊。

下面这张图总结了 Dockerfile 的核心流程和分层关系:

Dockerfile 分层构建与核心流程 Dockerfile + 上下文目录 docker build 分层构建引擎 镜像 可运行产物 分层缓存利用: Layer1: FROM node:20-alpine 缓存命中 ✓ Layer2: COPY package*.json ./ 缓存命中 ✓ Layer3: RUN npm install 缓存命中 ✓ Layer4: COPY . . 缓存过期 ✗ 优化策略:低频变化指令放前(FROM → COPY 依赖 → RUN 安装)→ 高频变化放后(COPY 源码) .dockerignore:过滤 node_modules、.git、日志等无用文件

常用指令速查表

指令 作用 注意点
FROM 指定基础镜像 必写指令,写在第一行
RUN 构建时执行命令 每行 RUN 增加一层,尽量合并
COPY 复制文件到镜像 用 .dockerignore 排除无用文件
CMD 容器启动命令 推荐 Exec 格式,一个 Dockerfile 只有最后一个生效
LABEL 添加元数据 用于维护者、版本等标注
EXPOSE 声明暴露端口 仅作文档说明,真正映射要在 docker run -p 指定
ENV 设置环境变量 构建和运行时都生效
WORKDIR 设置工作目录 之后指令基于此路径执行

最佳实践总结

  • 明确版本:不用 latest,写具体版本号或摘要
  • 合并 RUN:apt-get 更新安装和清理合并一行
  • 善用缓存:按变化频率从低到高排列指令
  • 精简上下文:.dockerignore 不能少
  • 多用 EXPOSE:别真等到运维问你这个容器开了什么端口
  • 标签清晰:docker build -t project:v1.2.3 . 比 project:latest 好一百倍

一点题外话:Dockerfile 写多了你会发现,它不仅能帮你构建镜像,还能当环境文档用。新同事来了,看看 Dockerfile 就知道项目依赖什么,怎么部署。