第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 的核心流程和分层关系:
常用指令速查表
| 指令 | 作用 | 注意点 |
|---|---|---|
| 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 就知道项目依赖什么,怎么部署。