Dockerfile实战:从手写指令到多阶段构建,一次搞清楚
一、为什么要学 Dockerfile?
你有没有遇到过这种情况:
花了半小时手动 docker commit 打了个镜像,第二天需要改一个配置,又要重走一遍——启动容器、进去改、再 commit。
或者同事拿到别人镜像,完全不知道里面装了什么,有什么环境变量,监听什么端口。
手动构建镜像有三个致命问题:
- 空间浪费:
docker commit每次都打全量镜像,层数越来越多,体积越来越大 - 修改繁琐:改一行配置就要重走启动→修改→commit 的完整流程
- 缓存失效:相同的步骤没法利用缓存,每次都重装一遍软件包
Dockerfile 是解法:基于配置文件自动构建镜像,把构建过程变成代码,可复现、可版本化、可利用缓存。
类比一下:手动构建的镜像是一道做好的"鱼香肉丝",你只能吃这一盘;Dockerfile 是做这道菜的"菜谱",任何人拿到菜谱都能做出来,还能随时改菜谱改口味。
二、Dockerfile 核心指令详解
Dockerfile 的构建流程很清晰:编写 Dockerfile → docker build 构建 → docker run 运行。
指令按功能分成四组,逐个拆解:
1. 基础三件套(必须掌握)
FROM:指定基础镜像,必须放第一行。
FROM alpine:3.22 # 推荐优先选 alpine,体积小、持续维护
FROM nginx:1.25-alpine # 官方优化过的应用镜像,直接用
RUN:在构建阶段执行 Shell 命令,每条 RUN 产生一个镜像层。
# 错误写法:三层 RUN,产生三个层,镜像体积大
RUN apk add nginx
RUN apk add openssh
RUN rm -rf /var/cache/apk
# 正确写法:用 && 合并,末尾清缓存,只产生一层
RUN apk add --no-cache nginx openssh
apk add --no-cache等价于apk add+rm -rf /var/cache/apk,一步到位。如果用的是 Debian/Ubuntu,对应apt-get update && apt-get install -y xxx && rm -rf /var/lib/apt/lists/*。
黄金原则:把相关命令合并进一条 RUN,末尾清缓存。
COPY 和 ADD:把宿主机文件拷贝进容器,两者的区别一定要记清楚:
| 指令 | 解压 tar 包 | 推荐场景 |
|---|---|---|
COPY | ❌ 不解压,原样拷贝 | 绝大多数情况用这个 |
ADD | ✅ 自动解压并删除源文件 | 只有需要解压 tar 包时才用 |
COPY config/nginx.conf /etc/nginx/conf.d/ # 拷贝配置文件
COPY scripts/start.sh / # 拷贝启动脚本
ADD softwares/bird.tar.gz /usr/share/nginx/html/ # 解压游戏包到指定目录
官方建议:优先用
COPY,语义更明确;ADD的自动解压行为容易让人困惑。
2. 变量:ENV vs ARG
这两个很容易搞混,本质区别是生效范围:
| 指令 | 生效阶段 | 容器运行时有没有 | 适用场景 |
|---|---|---|---|
ENV | 构建 + 运行 | ✅ 有 | 运行时需要的配置(DB地址、密码等) |
ARG | 仅构建阶段 | ❌ 没有 | 版本号、构建开关,构建完就消失 |
# ENV:传给容器的环境变量,容器启动后一直存在
ENV DB_HOST=10.0.1.5
ENV APP_PORT=8080
# ARG:仅在 docker build 阶段有效
ARG NGINX_VERSION=1.26.3
RUN wget http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz
# 构建完成后 NGINX_VERSION 不会进入容器
构建时可以传参覆盖 ARG 的默认值:
docker build --build-arg NGINX_VERSION=1.25.5 -t myapp:v1 .
3. 启动命令:CMD vs ENTRYPOINT
这是 Dockerfile 里最容易踩坑的地方,也是面试高频题。
| 指令 | 用户传命令时 | 适用场景 |
|---|---|---|
CMD | 被替换,用户命令直接覆盖 | 容器的默认启动命令,可以被覆盖 |
ENTRYPOINT | 被追加,用户命令作为参数传进来 | 固定的入口程序 |
# CMD ——会被覆盖
CMD ["nginx", "-g", "daemon off;"]
# docker run mynginx hello → CMD 被替换,执行的是 bash
# ENTRYPOINT ——不会被覆盖,用户参数变成它的参数
ENTRYPOINT ["nginx"]
# docker run myimage -g "daemon off;" → 执行 nginx -g "daemon off;"
最佳实践:组合使用
ENTRYPOINT ["nginx"] # 固定入口
CMD ["-g", "daemon off;"] # CMD 作为默认参数,用户可以传参覆盖
这样用户可以通过 docker run myimage -g "daemon off;" 覆盖默认参数,但不能绕开 nginx 入口。
4. 其他常用指令
LABEL author=liux hobby=ops # 打标签,key=value 格式,方便管理
EXPOSE 80 443 # 声明容器监听端口(仅声明,-p 才真正映射)
WORKDIR /usr/local/nginx/html # 设置工作目录,exec 进容器默认落在这里
USER nginx # 指定容器运行用户,默认 root
VOLUME /data # 指定持久化路径,会生成匿名存储卷
EXPOSE只是声明,不等于实际端口映射。用-p 80:80才是真正把容器端口映射到宿主机。-P(大写)会根据 EXPOSE 自动随机映射宿主机端口。
5. 进阶:HEALTHCHECK 健康检查
生产环境强烈推荐配置,让 Docker 知道容器里的服务是否真正在跑:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
| 参数 | 说明 |
|---|---|
--interval | 检查间隔,默认 30s |
--timeout | 单次检查超时时间,默认 30s |
--start-period | 容器启动后延迟多久开始检查(慢启动应用必配) |
--retries | 连续失败几次才判定 unhealthy,默认 3 次 |
状态流转:starting → healthy(检查通过)/ unhealthy(连续失败超过 retries)
三、实战:构建Nginx镜像
先说结论:如果只是部署 Nginx 静态站点,直接用
nginx:alpine就够了,完全不需要从 CentOS 裸机装起。 下面用一个完整案例演示标准写法。
1. 编写 Dockerfile
# 直接用官方 nginx alpine 镜像(约 23MB)
FROM nginx:alpine
# 拷贝 nginx 配置和启动脚本
COPY config/myapp.conf /etc/nginx/conf.d/
COPY scripts/start.sh /
# ADD 解压静态资源(tar.gz 自动解压)
ADD softwares/myapp.tar.gz /usr/share/nginx/html/
EXPOSE 88
# ⚠️ VOLUME 会生成匿名卷,生产环境建议用命名卷或宿主机挂载
VOLUME /usr/share/nginx/html/
# 以非守护模式启动 nginx(容器需要前台进程)
CMD ["nginx", "-g", "daemon off;"]
2. Nginx 配置(myapp.conf)
server {
listen 0.0.0.0:88;
root /usr/share/nginx/html/myapp/;
server_name myapp.liux.com;
}
alpine 版 nginx 的默认 web 根目录是
/usr/share/nginx/html/,配置文件在/etc/nginx/conf.d/。
3. 一键构建脚本(build.sh)
#!/bin/bash
VERSION=${1:-1}
# 先清理同名旧容器(避免名字冲突)
docker rm -f liux-app 2>/dev/null
# 构建镜像
docker build -t myapp:v0.${VERSION} -f Dockerfile .
# 启动容器
docker run --name liux-app -d -p 88:88 myapp:v0.${VERSION}
# 查看状态
docker ps -f name=liux-app
原版用
docker container inspect获取容器 IP——在现代 Docker bridge 网络下,宿主机不能直连容器 IP,用-p 80:80映射后直接访问localhost:80即可。
4. 执行构建
chmod +x build.sh
./build.sh 1
# ... 构建输出 ...
# Successfully tagged myapp:v0.1
# 验证服务
curl localhost:88 -I
# HTTP/1.1 200 OK — 部署成功

四、多阶段构建:自己编译带扩展模块的 Nginx
1.什么时候需要自己编译 nginx?
第三节用 nginx:alpine(23MB)非常轻量,但如果需要官方镜像不带的扩展模块,比如:
--with-stream— TCP/UDP 四层代理(官方 nginx:alpine 默认不带)--with-http_sub_module— 响应内容内联替换--with-http_stub_status_module— 连接状态监控--with-http_ssl_module— HTTPS/TLS 支持
这时候只能从源码编译。但编译工具链(build-base、pcre-dev、openssl-dev 等)几百 MB,全留在最终镜像里太浪费。
多阶段构建的解法:第一阶段装编译工具 → 编译 nginx → 第二阶段只拷编译产物,编译工具一个不留。
2. 对比:不拆阶段有多浪费
# ❌ 单阶段:编译工具 + nginx 全在最终镜像(~330MB)
FROM alpine:latest AS builder
# Alpine 包管理器使用 musl libc,部分国内 DNS 对 Fastly CDN
# 域名返回 SERVFAIL,nslookup 能拿到 IP 但退出码非零。
# 用 nslookup 拿 IP 写 /etc/hosts,再用 ; 忽略 nslookup 退出码
RUN nslookup dl-cdn.alpinelinux.org 223.5.5.5 > /tmp/dns.txt 2>&1; \
awk '/^Address: /{print $2; exit}' /tmp/dns.txt | \
xargs -I{} echo "{} dl-cdn.alpinelinux.org" >> /etc/hosts; \
apk add --no-cache \
build-base \
pcre-dev \
openssl-dev \
zlib-dev \
linux-headers \
wget
# 下载并编译(用 ARG 管理版本号)
# /etc/hosts 不跨 RUN 持久化,所以这条 RUN 里单独再解析 nginx.org
ARG NGINX_VERSION=1.26.3
RUN nslookup nginx.org 223.5.5.5 > /tmp/dns.txt 2>&1; \
awk '/^Address: /{print $2; exit}' /tmp/dns.txt | \
xargs -I{} echo "{} nginx.org" >> /etc/hosts; \
wget https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz && \
tar xf nginx-${NGINX_VERSION}.tar.gz && \
cd nginx-${NGINX_VERSION} && \
./configure \
--prefix=/usr/local/nginx \
--with-http_stub_status_module \
--with-http_ssl_module \
--with-http_sub_module \
--with-stream \
--without-http_gzip_module \
--without-http_autoindex_module && \
make -j$(nproc) && \
make install
#把nginx 加到 PATH,方便直接敲 nginx 命令
ENV PATH="/usr/local/nginx/sbin:${PATH}"
CMD ["nginx", "-g", "daemon off;"]
build-base、头文件、wget 全留在最终镜像里——这些都是运行时不需要的。


3. 多阶段 Dockerfile(编译带四个扩展模块的 nginx)
# ======= 第一阶段:编译 nginx =======
FROM alpine:latest AS builder
# Alpine 包管理器使用 musl libc,部分国内 DNS 对 Fastly CDN
# 域名返回 SERVFAIL,nslookup 能拿到 IP 但退出码非零。
# 用 nslookup 拿 IP 写 /etc/hosts,再用 ; 忽略 nslookup 退出码
# 分两次装:先装轻量依赖,再单独装 gcc(低内存机器一次装完容易 OOM Kill)
RUN nslookup dl-cdn.alpinelinux.org 223.5.5.5 > /tmp/dns.txt 2>&1; \
awk '/^Address: / && $2 !~ /:/ {print $2; exit}' /tmp/dns.txt | \
xargs -I{} echo "{} dl-cdn.alpinelinux.org" >> /etc/hosts; \
apk add --no-cache pcre-dev openssl-dev zlib-dev linux-headers wget make
RUN apk add --no-cache gcc musl-dev
# 下载并编译(用 ARG 管理版本号)
# /etc/hosts 不跨 RUN 持久化,所以这条 RUN 里单独再解析 nginx.org
ARG NGINX_VERSION=1.26.3
RUN nslookup nginx.org 223.5.5.5 > /tmp/dns.txt 2>&1; \
awk '/^Address: / && $2 !~ /:/ {print $2; exit}' /tmp/dns.txt | \
xargs -I{} echo "{} nginx.org" >> /etc/hosts; \
wget -4 https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz && \
tar xf nginx-${NGINX_VERSION}.tar.gz && \
cd nginx-${NGINX_VERSION} && \
./configure \
--prefix=/usr/local/nginx \
--with-http_stub_status_module \
--with-http_ssl_module \
--with-http_sub_module \
--with-stream \
--without-http_gzip_module \
--without-http_autoindex_module && \
make -j1 && \
make install
# ======= 第二阶段:运行时镜像 =======
FROM alpine:latest
# 只拷贝编译好的 nginx 目录,编译工具全部丢弃
COPY --from=builder /usr/local/nginx /usr/local/nginx
# nginx 运行时依赖的共享库(/etc/hosts 不跨 RUN,单独解析 DNS)
RUN nslookup dl-cdn.alpinelinux.org 223.5.5.5 > /tmp/dns.txt 2>&1; \
awk '/^Address: / && $2 !~ /:/ {print $2; exit}' /tmp/dns.txt | \
xargs -I{} echo "{} dl-cdn.alpinelinux.org" >> /etc/hosts; \
apk add --no-cache pcre openssl zlib
# 创建 nginx 用户和必要目录
RUN adduser -D -g '' nginx && \
mkdir -p /usr/local/nginx/logs /usr/local/nginx/client_body_temp && \
chown -R nginx:nginx /usr/local/nginx
# 拷贝业务配置和静态资源
COPY config/myapp.conf /usr/local/nginx/conf/conf.d/
ADD softwares/myapp.tar.gz /usr/local/nginx/html/
EXPOSE 80
USER nginx
# 把 nginx 加到 PATH,方便直接敲 nginx 命令
ENV PATH="/usr/local/nginx/sbin:${PATH}"
CMD ["nginx", "-g", "daemon off;"]
docker build -t mynginx:v0.1 -f Dockerfile .


4. 效果对比

| 构建方式 | 镜像内容 | 最终大小 |
|---|---|---|
| 单阶段 | 编译工具 + nginx + 静态资源 | ~330MB |
| 多阶段 | 只有 nginx(带四个扩展模块)+ alpine 运行时 | ~20MB |
编译工具一个不剩,但 stream / sub / stub_status / ssl 四个模块全在。
验证编译进去了的模块:
docker run --rm mynginx:v0.1 nginx -V 2>&1 | grep with-

5. 多阶段构建的核心语法
# 给阶段命名(后续 COPY --from 引用)
FROM xxx AS 阶段名
# 从指定阶段拷贝文件(可跨阶段引用)
COPY --from=阶段名 /src/path /dest/path
# 从上一个 FROM 阶段拷贝(阶段索引从 0 开始)
COPY --from=0 /src/path /dest/path
6. 多阶段适用的典型场景
| 场景 | 第一阶段 | 第二阶段 |
|---|---|---|
| nginx 加扩展模块 | alpine + 编译工具 | alpine(只拷编译好的 nginx) |
| Go 服务 | golang:alpine(编译) | alpine / scratch(运行二进制) |
| Java 服务 | maven/gradle(构建 jar) | eclipse-temurin:jre-alpine(运行) |
| 前端 SPA | node:alpine(npm build) | nginx:alpine(静态服务) |
| Python wheel | python(pip install/build) | python:alpine(仅 wheel + runtime) |
一句话决策:官方镜像够用 → 直接用;需要额外模块/定制编译 → 多阶段构建。
五、Dockerfile 优化五原则
1. 充分利用缓存
Docker 构建时会逐层检查缓存。一旦某一层变了,后面所有层的缓存全部失效。
# 错误顺序:COPY 代码在前,apk add 在后
# 每次改代码,apk add 都要重跑
COPY . /app
RUN apk add --no-cache nginx
# 正确顺序:不变的放前面,频繁变的放后面
RUN apk add --no-cache nginx # 不经常变,缓存有效
COPY . /app # 经常变,放最后
2. 合并 RUN 减少层数
# 一条 RUN 一个层,12 条 = 12 个层
RUN apk add gcc
RUN apk add make
# ...
# 合并 + 清缓存,只有 1 个层
RUN apk add --no-cache gcc make wget
3. 用 .dockerignore 排除无用文件
# .dockerignore
.git
*.log
node_modules
*.md
tests/
避免把无关文件发给 Docker daemon,加快构建上下文传输。
4. 选择最小基础镜像
alpine ~5MB ← 最小,部分软件可能不兼容
ubuntu:22 ~72MB ← 兼容性好,适合大多数服务
centos:7 ~200MB ← 老项目迁移
生产环境优先考虑 Alpine,如果出现库不兼容再降级到 Ubuntu。
5. 非 root 用户运行服务
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
CMD ["nginx", "-g", "daemon off;"]
以 root 运行容器存在安全风险,能用普通用户的场景尽量用。
六、镜像的三种管理方式
除了 Dockerfile,生产中还会用到另外三种镜像管理手段:
# 方式一:docker commit(基于运行中容器打快照)
docker commit -a 'liux' -m '修复 bug' 容器id 镜像名:v0.2
# 缺点:层数多、体积大,不可复现,不推荐正式环境
# 方式二:export/import(导出容器文件系统)
docker export -o myapp.tar.gz 容器id
docker import myapp.tar.gz myapp:1.0
# 注意:丢失所有历史层,只有一层,镜像历史信息全丢
# 方式三:save/load(导出完整镜像,保留所有层)
docker save -o myapp.tar.gz myapp:1.0
docker load -i myapp.tar.gz
# 推荐:他完整保留镜像层和历史,适合离线迁移和备份,生产推荐使用
| 方式 | 适用场景 | 优缺点 |
|---|---|---|
commit | 临时保存实验改动 | 快,但不可复现、体积大 |
export/import | 容器文件系统迁移 | 只有一层,历史全丢 |
save/load | 生产镜像离线迁移、备份 | 完整保留层,体积稍大 |
七、总结
Dockerfile 的核心逻辑就一句话:把手工操作变成代码,让构建过程可复现、可版本化、可利用缓存。
落地清单(直接抄):
- 基础镜像优先选 alpine,不行再选 ubuntu:22,不要再用 centos:7(已 EOL)
- 相关 RUN 合并,用
apk add --no-cache或apt-get install -y xxx && rm -rf /var/lib/apt/lists/*清缓存 - 不变的指令(RUN 安装软件)放前面,频繁变的(COPY 代码)放后面
- 文件复制:默认用
COPY,只有需要解压 tar 包才用ADD - 启动命令:固定入口用
ENTRYPOINT,可覆盖的默认参数用CMD - 变量:运行时需要的用
ENV,构建阶段用ARG;敏感信息(密码/token)不要写死在 ENV 里 - 需要编译的场景(Go/Rust/Java/前端/nginx扩展模块):上多阶段构建,
COPY --from=阶段名只拷编译产物 - 有官方镜像的中间件(nginx/redis/mysql):直接用官方镜像,不需要多阶段构建
- 容器里不要装 sshd,一个容器一个进程,调试用
docker exec - 配好
.dockerignore,别把.git和node_modules发给 Docker daemon
更多推荐
所有评论(0)