一、为什么要学 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,末尾清缓存。


COPYADD:把宿主机文件拷贝进容器,两者的区别一定要记清楚:

指令解压 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 次

状态流转:startinghealthy(检查通过)/ 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 — 部署成功

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

image-20260622232122115

四、多阶段构建:自己编译带扩展模块的 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(运行)
前端 SPAnode:alpine(npm build)nginx:alpine(静态服务)
Python wheelpython(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-cacheapt-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,别把 .gitnode_modules 发给 Docker daemon

更多推荐