恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从零写出生产级 Dockerfile:核心指令 + 层缓存 + 多阶段构建,告别臃肿镜像
首页
资讯中心
/
从零写出生产级 Dockerfile:核心指令 + 层缓存 + 多阶段构建,告别臃肿镜像
从零写出生产级 Dockerfile:核心指令 + 层缓存 + 多阶段构建,告别臃肿镜像
发布时间:2026/8/13 1:21:56
本文首发于 栏轩·阁欢迎访问阅读原文获取更好的阅读体验。一、Dockerfile 是什么Dockerfile 是一个文本文件里面写的是怎么构建一个镜像的指令。FROM alpine:latest RUN echo hello /tmp/test CMD [cat, /tmp/test]每一行指令最终会变成镜像里的一层。Docker 按顺序执行这些指令最终产出可以运行的镜像。做一个 Dockerfile 需要什么Docker 已安装✅一个文本编辑器一个想容器化的应用没有就用个脚本练手二、核心指令先跑起来1.FROM— 基础镜像一切从这里开始每个 Dockerfile 必须以FROM开头。FROM alpine:latest # Alpine Linux极小 (~7MB) FROM python:3.12-slim # Python 运行环境 FROM nginx:alpine # Nginx Alpine FROM ubuntu:22.04 # Ubuntu 完整系统 FROM scratch # 空镜像从零开始基础镜像决定了你的镜像起点有多大基础镜像大小适用场景alpine~7 MB静态二进制、轻量服务python:3.12-slim~50 MBPython 应用nginx:alpine~27 MBWeb 服务ubuntu~80 MB需要完整系统工具2.RUN— 构建时执行命令RUN apk add --no-cache curl RUN pip install -r requirements.txt RUN echo 构建时执行 /tmp/test关键点RUN在构建时执行每一行RUN都会增加一层。为什么合并 RUN 和清理缓存很重要先看一个实验# ❌ 两个 RUN先创建文件再删除 FROM alpine:latest RUN dd if/dev/zero of/bigfile bs1M count10 # 创建 10MB 文件 RUN rm /bigfile # 删除这个文件构建结果23.4 MB原因用docker history看每一层SIZE 层内容 10.5MB 第 1 个 RUN创建 /bigfile10MB 文件在这层留下了 4.1kB 第 2 个 RUNrm /bigfile只是记了个删了文件还在上一层每一层都是一个独立的快照。第 2 层的删除只是在第 2 层标记了已删除但第 1 层的 10MB 文件依然存在。第 2 层记了已删除 ← 这只是个标记 第 1 层10MB 文件 ← 文件还在 基础镜像文件只是被挡住了不是真的没了。这就是为什么镜像比你想象的大。# ✅ 一个 RUN创建并删除文件没进过镜像 FROM alpine:latest RUN dd if/dev/zero of/bigfile bs1M count10 rm /bigfile构建结果12.9 MB差了一倍SIZE 层内容 4.1kB 同一个 RUN 创建又删除文件从来没进入任何一层放到apt的场景也是一样的道理# ❌ 错误写法 RUN apt-get update # 把包列表下载到 /var/lib/apt/lists/~30MB RUN apt-get install -y curl # 装 curl RUN rm -rf /var/lib/apt/lists/* # 在不同层删除包列表 # 实际30MB 的包列表还在上一层最终镜像白白多了 30MB # ✅ 正确写法 RUN apt-get update apt-get install -y curl rm -rf /var/lib/apt/lists/* # 下载→安装→删除 在同一层包列表没进过任何一层原则同一个逻辑的操作放在一个RUN里用连接。3.CMD— 容器启动时执行的命令CMD [python, app.py] # ✅ 推荐写法JSON 数组格式 CMD python app.py # ❌ shell 格式不推荐CMD和RUN最大的区别指令什么时候执行执行几次RUN构建时执行1 次构建时CMD运行时执行docker run每次启动容器都执行测试验证FROM alpine:latest RUN echo RUN构建时执行 CMD [echo, CMD运行时执行]# 构建时RUN 执行dockerbuild-ttest.# 运行时CMD 执行dockerrun--rmtest# 输出CMD运行时执行4.WORKDIR— 工作目录WORKDIR /app # 之后的 COPY、RUN、CMD 都在 /app 目录下执行WORKDIR等价于cd但更好——目录不存在时会自动创建。WORKDIR /app # 自动创建 /app COPY app.py . # 复制到 /app/app.py RUN pwd # 输出 /app CMD [python, app.py] # 从 /app 启动5.COPY— 把文件放进镜像# 把本地的 index.html 复制到镜像的 /usr/share/nginx/html/ COPY index.html /usr/share/nginx/html/ # 配合 WORKDIR 使用更简洁 WORKDIR /usr/share/nginx/html COPY index.html . # 复制到当前工作目录COPYvsADD指令区别COPY只复制文件不做其他事。推荐优先用ADD复制文件 自动解压 tar 支持 URL。只在需要解压时用三、层缓存 — 为什么顺序很重要Docker 的构建是分层的。每一层构建完会缓存起来下次同一层没有变化就直接用缓存。错误写法改代码就要重装依赖COPY . . RUN pip install -r requirements.txt # ← 代码改了依赖也要重装正确写法依赖放前面代码放后面COPY requirements.txt . # ← requirements.txt 不常变走缓存 RUN pip install -r requirements.txt # ← 走缓存不用重装 COPY . . # ← 只重新复制代码验证效果# 第一次构建正常安装dockerbuild-tmyapp.# pip install... 耗时 20s# 第二次构建代码改了依赖没改dockerbuild-tmyapp.# COPY requirements.txt → CACHED# RUN pip install → CACHED ← 走缓存# COPY . → 重新复制代码# 总耗时 1秒原则把不常变的东西放前面常变的东西放后面。四、多阶段构建 — 镜像瘦身的核心技巧“编译环境要大没关系运行环境要小”。用多个FROM每个FROM是一个阶段。最后只取需要的产物。场景Go 语言应用# 阶段 1编译 FROM golang:alpine AS build # 364 MB包含 Go 编译器 WORKDIR /src COPY server.go . RUN go build -o /server server.go # 阶段 2运行 FROM alpine:latest # ~7 MB只有系统 COPY --frombuild /server /server CMD [/server]关键指令COPY --from从其他阶段复制文件而不是从宿主机。COPY --frombuild /server /server # 从 build 阶段复制 COPY --fromnginx:alpine /usr/share/nginx/html/index.html /index.html # 从其他镜像复制最终效果镜像大小golang:alpine仅编译用364 MB丢弃alpine:latest基础系统7 MB最终产物21 MB编译需要的 Go 编译器、工具链全部被丢掉了只留编译好的二进制。适用于任何语言# Python 也可以多阶段 FROM python:3.12-slim AS builder COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt FROM python:3.12-slim COPY --frombuilder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages COPY app.py . CMD [python, app.py]多阶段构建的核心思想每个阶段各司其职最终产物只保留运行时需要的东西。五、进阶指令1.ARGvsENV— 两种变量指令作用时间作用域覆盖方式ARG构建时只在docker build时存在--build-arg VERSION2.0ENV运行时容器启动后仍然存在-e APP_ENVdevARG VERSION1.0 # 默认值 1.0构建时可覆盖 ENV APP_ENVproduction # 运行时的环境变量 # 如果需要把 ARG 的值带到运行时赋值给 ENV ARG VERSION ENV APP_VERSION$VERSION# 构建时修改 ARGdockerbuild --build-argVERSION2.0-tmyapp.# 运行时修改 ENVdockerrun-eAPP_ENVdevelopment myapp2.ENTRYPOINTvsCMD— 入口 vs 默认参数两个都是容器启动时执行的命令区别在于能不能被覆盖。ENTRYPOINT [echo] # 主程序固定不可变 CMD [Hello, World!] # 默认参数可以被覆盖运行方式效果docker run image执行echo Hello, World!docker run image Hi执行echo Hi覆盖了 CMDdocker run --entrypoint bash image覆盖 ENTRYPOINT组合模式# ENTRYPOINT 是这个容器是干什么的 # CMD 是默认怎么干 ENTRYPOINT [python] CMD [app.py]3.HEALTHCHECK— 健康检查Docker 默认只看进程是否活着不管服务能不能用。HEALTHCHECK让 Docker 主动检查你的服务。HEALTHCHECK --interval10s --timeout3s --retries2 \ CMD curl -f http://localhost:5000/ || exit 1参数说明--interval每隔多久检查一次--timeout每次检查超时时间--retries连续失败几次算不健康--start-period启动后等多久再开始检查查看健康状态dockerinspect 容器名--format{{.State.Health.Status}}# starting → healthy / unhealthy4.EXPOSE— 声明端口只是文档EXPOSE 8080仅仅是个说明告诉用户这个容器监听 8080 端口。不真的暴露端口。要暴露还是要加-p。5.USER— 别用 root生产环境不要用 root 运行应用。但USER app不会自动创建用户必须先创建再切换FROM alpine:latest # 第一步创建用户 RUN addgroup -S app adduser -S app -G app # 第二步切换到该用户 USER app常见错误只USER不创建USER nonexistent # ← 这个用户不存在构建不会报错但运行时直接挂掉docker: Error response from daemon: unable to find user nonexistent不同基础镜像创建用户的命令基础镜像创建用户命令Alpineadduser -S app -G appUbuntu/Debianuseradd -r -s /bin/false app通用RUN addgroup -S app adduser -S app -G app六、完整的生产级 Dockerfile实际项目下面两个是我实际生产在用的 Dockerfile分别对应 Java 后端和 Next.js 前端。Java / Spring Boot 后端# ── 阶段 1编译 ── # Maven JDK 21 的构建环境镜像很大但只是临时用 FROM maven:3.9-eclipse-temurin-21 AS build # 设置工作目录之后所有命令都在 /app 下执行 WORKDIR /app # 先复制 pom.xml利用层缓存pom.xml 不改就不重装依赖 COPY pom.xml . # 复制 Maven 私服配置如果有私有仓库 COPY .mvn/settings.xml settings.xml # 离线下载所有依赖go-offline 会下载 pom.xml 定义的所有 jar 包但只下载不编译 RUN mvn dependency:go-offline -B -s settings.xml # pom.xml 和 settings.xml 的层缓存命中后才复制源码 COPY src ./src # 真正编译打包跳过测试 RUN mvn clean package -DskipTests -B -s settings.xml # ── 阶段 2运行 ── # 只用 JREJava 运行环境没有编译器小得多 FROM eclipse-temurin:21-jre WORKDIR /app # 从 build 阶段复制打好的 jar 包 COPY --frombuild /app/target/ROOT.jar app.jar # 声明容器内端口只起文档作用真要暴露还得 docker run -p EXPOSE 8080 # 容器启动命令JSON 数组格式正确接收 SIGTERM 信号 ENTRYPOINT [java, -jar, /app/app.jar]用了什么技巧技巧对应指令多阶段构建FROM maven:... AS build→FROM eclipse-temurin:21-jre层缓存加速COPY pom.xml在前COPY src在后依赖只装一次JSON 格式ENTRYPOINT [java, -jar, app.jar]✅产物分离编译阶段 2GB运行阶段只用 JRE小得多Next.js 前端# ── 阶段 1构建 ── # Node.js 22 的编译环境装依赖 打包 FROM node:22-alpine AS builder WORKDIR /app # 先复制依赖描述文件利用层缓存加速 COPY package.json package-lock.json ./ # npm ci 比 npm install 更快更严格完全按 lock 文件安装 RUN npm ci # 复制所有源码这层常变但上面的依赖层已经缓存了 COPY . . # 删除 postbuild 脚本避免构建时触发额外操作然后构建 RUN npm pkg delete scripts.postbuild npm run build # ── 阶段 2运行 ── FROM node:22-alpine AS runner WORKDIR /app # 设置生产环境变量 ENV NODE_ENVproduction # 只复制运行需要的文件不要 node_modules 里的 devDependencies COPY --frombuilder /app/.next/standalone ./ COPY --frombuilder /app/.next/static ./.next/static COPY --frombuilder /app/public ./public # 声明端口文档作用 EXPOSE 3000 # 启动 Node.js 服务 CMD [node, server.js]用了什么技巧技巧对应指令多阶段构建builder装依赖 构建runner只跑产物层缓存package.jsonpackage-lock.json先复制npm ci走缓存按需复制只复制运行需要的文件standalone、static、publicnpm ci比npm install更快更严格锁定版本七、总结指令速查表指令作用执行时机FROM指定基础镜像必需第一行RUN执行命令构建时构建时CMD容器启动命令运行时COPY复制文件到镜像构建时WORKDIR设置工作目录构建时ARG构建时变量构建时ENV运行时环境变量构建时 运行时ENTRYPOINT容器入口程序运行时HEALTHCHECK健康检查运行时EXPOSE声明端口文档—USER切换用户构建时 运行时Dockerfile 最佳实践原则说明不常变的放前面利用层缓存加速构建每个 RUN 只做一件事但同一件事的多个命令用合并减少层数多阶段构建编译环境和运行环境分离镜像体积差 10 倍以上不用 root 运行建一个普通用户USER app不要用ADD除非要解压优先用COPY.dockerignore过滤掉node_modules、.git等无用文件尽量用 Alpine基础镜像越小越好