ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Docker镜像优化实战:多阶段构建从1.7GB到200MB

Docker镜像优化实战:多阶段构建从1.7GB到200MB 先把背景说清楚我们团队有个维护了两三年的老项目Docker 镜像长期稳定在 1.7GB 左右CI 构建一次要十几分钟开发同学每次拉镜像到本地调试都要先刷一杯咖啡才有耐心等。后来我把所有服务的 Dockerfile 整体重写了一遍核心是引入多阶段构建再配合基础镜像瘦身、构建上下文清理、依赖缓存复用这些镜像优化手段最终镜像降到 200MB 上下CI 构建时间也压缩了一半。这篇文章就是这次重构的完整复盘。不管是正在维护 Docker 镜像、被体积和构建速度折磨的老手还是刚接触 Docker 想直接写出干净镜像的新人应该都能从这里拿走点东西。1. 镜像体积膨胀的根源构建依赖和运行时依赖被塞进同一层1.1 一个反面教材 Dockerfile 的体积账本很多仓库里的 Dockerfile 是这么写的FROM ubuntu:20.04 RUN apt-get update apt-get install -y \ build-essential \ openjdk-11-jdk \ maven \ git \ vim WORKDIR /app COPY . . RUN mvn package -DskipTests EXPOSE 8080 CMD [java, -jar, target/app.jar]这个写法非常经典也非常典型地把所有依赖混在一起。把账算一下就很清楚ubuntu:20.04 基础镜像大概 70MBopenjdk-11-jdk 装完接近 350MBmaven 和它的依赖加起来 50MB 以上build-essential 这类编译工具链又是 150MB 起步再加上 git、vim 和 apt 缓存的 /var/lib/apt/lists 目录随随便便就到 700MB 以上。如果项目本身还有第三方依赖、静态资源、字库文件冲到 1GB 根本不奇怪。问题在于这个镜像真正运行 Spring Boot 应用时需要的只是 JRE、一个 jar 包了不起加个字体文件目录。JDK 里的编译器、调试器、javadoc 工具maven 里那套构建引擎build-essential 里的 gcc、make还有 vim、git这些都是纯纯的工地状态物资运行阶段完全用不到。你等于把整个工地搬进了住宅然后每个房间住一天就再也不用。1.2 分层机制为什么删除救不了镜像体积刚接触 Docker 的人常有一个误解既然这些工具用不到那我在 Dockerfile 里删掉不就行了这里就涉及 Docker 镜像的分层机制了。Docker 镜像是用只读层堆出来的每条 RUN、COPY 指令都会生成一个或多个新层。哪怕你在新一层里rm -rf删了文件文件的数据依然保留在更底层的镜像历史里只是被白化处理对上层看起来像是删掉了但存储空间一点都不会省。换句话说镜像体积是只增不减的。所以RUN apt-get install ...后面另起一行写RUN rm -rf /var/lib/apt/lists/*基本是无效操作——除非安装和清理在同一个 RUN 指令里连着做让最终这一层只记录安装完成且缓存已清的结果。这也是为什么我后面会反复强调镜像瘦身的本质不是删掉不要的文件而是从一开始就不让不要的文件进入最终镜像。1.3 看不见的体积构建上下文和宿主机文件还有一种体积暴涨是看不见的来自构建上下文。docker build -t xxx .里的这个.会把整个目录打包发给 Docker 守护进程如果目录里有target/、node_modules/、.git/甚至几百 MB 的录屏、日志文件它们也会一起去参与这次构建的上下文传输。有些人会觉得我 COPY 的时候没复制这些应该没关系吧但实际构建时Docker 要先压缩再解压整个上下文CI 时间就是这么被吃掉的。这个问题的解法很简单就是.dockerignore我在第 4 章会专门讲怎么写。但先记住一个判断标准构建上下文的大小决定的是构建前期的传输耗时COPY 进镜像的文件大小决定的是最终交付物体积。这两个问题经常被混在一起排查时要分开看。2. 多阶段构建构建环境与运行环境的彻底解耦2.1 从一个 Dockerfile 里的两个 FROM 说起多阶段构建的核心思想一句话就能概括一个 Dockerfile 里允许出现多个FROM前面几个阶段负责造东西最后一个阶段只负责把造好的东西拿过来跑。同一个 Java 项目的多阶段写法直接把第 1 章的反面教材改成下面这样# 阶段一构建 FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 阶段二运行 FROM openjdk:11-jre-slim COPY --frombuilder /app/target/app.jar /app/app.jar EXPOSE 8080 CMD [java, -jar, /app/app.jar]关键点在两个地方。一是AS builder给阶段起了名字后面才能引用二是COPY --frombuilder这是跨阶段复制把构建阶段生成的文件复制到运行阶段。最终交付的镜像只包含最后一个FROM openjdk:11-jre-slim以及它之后执行的指令。builder 阶段里那 1GB 多的 maven、JDK、编译产物全部停留在构建过程里不会出现在最终镜像中。这是多阶段构建最直接的价值从镜像结构层面根治了第 1 章说的构建依赖和运行依赖混杂问题。2.2 依赖层隔离三阶段的玩法两阶段模型已经能搞定大部分场景但如果你想进一步优化构建缓存的命中率可以用三阶段依赖下载、编译打包、运行。# 依赖阶段只下载依赖单独缓存 FROM maven:3.8-openjdk-11 AS deps WORKDIR /work COPY pom.xml . RUN mvn dependency:go-offline # 构建阶段复用依赖缓存只做编译 FROM maven:3.8-openjdk-11 AS builder WORKDIR /work COPY --fromdeps /root/.m2 /root/.m2 COPY pom.xml . COPY src ./src RUN mvn package -DskipTests # 运行阶段 FROM openjdk:11-jre-slim COPY --frombuilder /work/target/app.jar /app/app.jar CMD [java, -jar, /app/app.jar]这样做的好处是只要pom.xml没变依赖下载阶段就能命中层缓存后面每次构建只需要重新编译源文件。对于 Node 和 Python 项目这个思路同样适用拆出来的依赖层会非常值得缓存。实际维护中你会发现多阶段构建不仅省了最终镜像的存储还省了每次构建的时间。2.3 除了体积多阶段构建还解决了什么很多人聊多阶段构建只谈镜像瘦身但对生产环境说另外两个价值可能更重要。第一是安全性。单阶段 Dockerfile 会把编译工具、源文件、甚至构建时产生的密钥、配置文件都留在最终镜像里镜像一旦被拖走信息是一起带走的。多阶段构建让最终镜像只包含运行必需的可执行文件和依赖攻击面小得多。第二是可复现性。构建阶段用固定 tag 的基础镜像比如maven:3.8-openjdk-11配合锁定的依赖清单可以让在哪构建、什么时候构建产出的东西都一致。我见过不少项目因为本地和 CI 用的基础镜像 tag 漂移导致编译行为和运行行为不一致多阶段构建不会自动解决这个问题但它把构建环境和运行环境分开之后这种不一致至少能被更快定位。3. 按语言场景拆解Java、Node、Python 的多阶段 Dockerfile 实战3.1 Java/Spring Boot从 MavenJDK 到 JRE SlimJava 场景最典型的优化就是用 JRE 替代 JDK。构建阶段用包含 Maven 和 JDK 的完整镜像运行阶段用精简 JRE 镜像。FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM openjdk:11-jre-slim RUN groupadd -r app useradd -r -g app app USER app COPY --frombuilder /app/target/app.jar /app/app.jar EXPOSE 8080 ENTRYPOINT [java, -Xmx512m, -jar, /app/app.jar]这里我额外加了一个非 root 用户切换。用 USER app 之后容器内进程不再是 root即使镜像被攻破攻击者拿到的权限也有限。这个是生产环境的基本要求很多人只关注体积把这一点漏了。顺手说一下 IDEA 打包 Docker 镜像。IntelliJ IDEA 内置了 Docker 插件可以配置远程/本地 Docker直接右键 Dockerfile 构建和运行。使用 IDEA 构建时建议在 Run Configuration 里把Dockerfile指定到项目根目录构建上下文用项目根目录而不是 Dockerfile 所在子目录否则容易踩构建上下文没有包含 src的坑。本质上 IDE 只是壳最终的镜像好坏还是看 Dockerfile 怎么写。优化前后的对比我用同一个 Spring Boot 服务实测过方案基础镜像最终体积单阶段 ubuntu JDK Mavenubuntu:20.041.2GB两阶段 Maven JREopenjdk:11-jre-slim230MB两阶段 jlink 裁剪 JREopenjdk:11-jre-slim jlink160MBjlink 是 Java 9 之后提供的 JRE 裁剪工具可以只保留运行需要的模块。如果项目对体积极度敏感可以在 builder 阶段用 jlink 生成最小运行时再把裁剪后的 JRE 目录复制到运行阶段体积能更小。代价是维护成本高一点模块依赖判断需要点时间一般小团队没那么必要。3.2 Node.jsnpm ci 与生产依赖的精简Node 项目的多阶段构建重点是区分生产依赖和开发依赖。开发依赖里有 typescript、eslint、jest 这些运行时根本不需要。FROM node:18-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build npm prune --omitdev FROM node:18-alpine ENV NODE_ENVproduction WORKDIR /app COPY --frombuilder /app/package.json /app/package-lock.json ./ COPY --frombuilder /app/node_modules ./node_modules COPY --frombuilder /app/dist ./dist USER node CMD [node, dist/server.js]注意这里用了npm ci而不是npm install。npm ci严格按package-lock.json安装不自动更新依赖构建可复现性高速度也更快。安装完依赖之后跑构建再npm prune --omitdev把开发依赖删掉只保留生产依赖。如果项目里涉及原生模块比如bcrypt、sharp这类需要编译 C/C 代码的 npm 包builder 阶段可能需要apk add python3 make g之类的编译工具链这些工具同样不会进入最终镜像。但要注意原生模块编译时会绑定当前平台的 ABIbuilder 和 runner 必须用同一个基础镜像系列比如都是 alpine否则编译出来的.node文件在运行阶段会报ERR_MODULE_NOT_FOUND或者非法指令崩溃。3.3 Python虚拟环境与系统包的取舍Python 镜像优化的常见思路是把虚拟环境整个复制到运行阶段。这样做的好处是运行环境干净依赖都在一个独立目录里系统包不会污染镜像。FROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt . RUN python -m venv /opt/venv \ /opt/venv/bin/pip install --no-cache-dir -r requirements.txt FROM python:3.11-slim WORKDIR /app COPY --frombuilder /opt/venv /opt/venv COPY . . ENV PATH/opt/venv/bin:$PATH CMD [python, app.py]运行阶段通过ENV PATH把虚拟环境的 bin 目录放进 PATH这样python和脚本入口都能直接找到。另一个思路是pip install --target把依赖装到一个独立目录而不是虚拟环境效果类似。两种方式在最终体积上差别不大选一种固定下来就行。Python 项目有一个比体积更值得注意的问题alpine 镜像基于 musl libc与常规 Linux 发行版的 glibc 不兼容。pandas、numpy这类包从 PyPI 上拿到的通常是针对 glibc 平台编译的 wheel在 alpine 上会直接装不上或者运行时报错。所以 Python 项目我默认用python:3.11-slim这种 Debian 系镜像而不是 alpine。体积确实比 alpine 大几十 MB但生态兼容性远高于那点存储成本。4. 多阶段之外镜像优化工具箱里的七件套多阶段构建是骨架但要真正把镜像压到很小的量级还得和下面这些手段配合。4.1 基础镜像的选型不是越精简越好基础镜像体积量级适合场景注意点ubuntu70MB 起兼容性优先习惯 Ubuntu 环境的团队体积大默认包多debian:bookworm-slim30MB 起需要 glibc、包管理完善的场景性价比很高alpine5MB 起静态编译、纯 Go/Rust、无原生依赖musl 兼容性问题distroless20MB 起追求最小攻击面无 shell 环境调试困难scratch0静态二进制只适合 Go/Rust 等静态编译语言选基础镜像的核心原则是能用系统包管理器装齐依赖就选 slim 系能用静态编译就选 scratch只有在真正需要极致体积而且跑的是纯编译语言时才锁定 alpine。初学者最大的误区是一上来就 alpine然后被 musl 兼容性坑得体无完肤。4.2 .dockerignore 的正确写法.dockerignore的作用和.gitignore类似但它是管构建上下文的。一个典型的 Java 项目可以这样写.git target node_modules *.log .idea .vscode .dockerPython 项目加一行__pycache__和.venvNode 项目可以把dist也忽略掉反正在容器里重新构建。如果不写.dockerignore把几个 GB 的本地缓存目录带进构建上下文再好的 Dockerfile 也快不起来。判断标准很简单docker build输出的Sending build context to Docker daemon那一行如果体积超过几百 MB先回去检查.dockerignore。4.3 RUN 合并与包管理器缓存清理多条 RUN 指令尽量用连到一起尤其是装软件包和清理缓存的场景RUN apt-get update \ apt-get install -y --no-install-recommends \ curl \ rm -rf /var/lib/apt/lists/*--no-install-recommends可以阻止 apt 安装推荐的额外包能省不少体积。同理pip install --no-cache-dir、npm ci之后手动清理缓存目录都是同一个思路同一层内完成安装 清理避免垃圾留在层历史里。4.4 依赖先行用 COPY 顺序把缓存命中率拉满Docker 构建缓存是按层判断的某一层如果 COPY 的内容变了这一层及之后的缓存全部失效。所以要把不常变的东西放在 Dockerfile 前面。典型错误是把COPY . .写在RUN npm ci前面导致每次改一行源码整个依赖安装也要重跑。正确姿势是先把package.json、requirements.txt这些依赖清单复制进去装完依赖最后才复制源码。这样只有依赖清单变化时依赖层才失效平时改代码只会触发最后一层重新构建速度差距是数量级的。4.5 BuildKit 的 --output把构建产物递出镜像多阶段构建的构建阶段产物默认只存在于中间层里最终镜像只有运行阶段。但在 CI 场景里你可能有我只想拿到编译产物不想要 Docker 镜像的需求。这时可以用 BuildKit 的--outputdocker build --target builder --output typelocal,dest./out .这条命令会把 builder 阶段的工作目录导出到本地./out完全不生成最终镜像。配合--target参数你可以把构建产物和运行镜像两个诉求拆开。在交付流水线里这是一个很实用的小技巧。4.6 镜像下载慢registry mirror 与内网仓库很多团队会遇到公共镜像拉取缓慢的问题。Docker 官方仓库在某些网络环境下确实不稳定常规解法是给 Docker 配置 registry mirror。修改/etc/docker/daemon.json{ registry-mirrors: [https://your-mirror.example.com] }改完记得systemctl restart docker让配置生效。如果公司在多台服务器都要部署更推荐自建一套镜像仓库Registry 或者 Harbor 都行。先把常用基础镜像 push 到内网仓库所有机器都从内网拉彻底避开公共仓库的网络波动。镜像仓库同时还可以配合镜像清理策略避免基础镜像版本堆积占满磁盘。4.7 多阶段构建与 docker compose 的搭配docker compose 支持在 build 配置里指定构建目标阶段services: app: build: context: . target: builder开发环境可以用target: builder拿到带调试工具的完整环境生产环境用默认的最终阶段一份 Dockerfile 兼顾两种场景。比如 Node 项目开发时希望有热重载和 devDependencies生产时只跑编译后的产物这个 target 切换就很有用。5. 优化过程中最容易踩的坑和完整排查思路5.1 缓存失效的元凶COPY 顺序和基础镜像 tag我在第 4 章说了依赖先行但实际项目里还是有人踩坑。比如这样写FROM node:18-alpine WORKDIR /app COPY . . RUN npm ci RUN npm run build每次改源码COPY . .这一层内容变了后面的npm ci全部重跑。这种写法的直接表现是我就改了一行文案构建却花了几分钟。把COPY . .挪到RUN npm ci之后问题立刻消失。另一个容易被忽略的是基础镜像 tag 漂移。node:18-alpine这个 tag 会跟着镜像仓库更新今天构建和三个月后构建可能拉到的并不是同一个镜像内容。在严格的项目里建议锁定 digestFROM node:18-alpinesha256:xxxxx。这样能保证构建缓存稳定也避免本地能跑、CI 跑挂了这种莫名其妙的差异。5.2 小镜像不等于好镜像glibc、调试和权限问题说个我踩过的真实案例。一个 Node 服务为了追求小体积把基础镜像换成了node:18-alpine跑一阵子之后用户反馈偶发 500看日志是bcrypt相关的二进制加载失败。原因是 alpine 用的 musl libc和bcrypt预编译二进制所依赖的 glibc 不兼容。换成node:18-bookworm-slim之后问题彻底消失体积只多 30MB 左右。还有 distroless 镜像体积小、攻击面小但容器里连/bin/sh都没有出问题只能靠日志没法docker exec进去排查。用不用它取决于你的运维能力没有 shell 的环境对你的故障定位和排障流程是有要求的。对小团队来说先保证能调试再考虑极致瘦身。镜像优化的目标不是无限压缩体积而是在功能完整、可维护、可排障的基础上尽量精简。5.3 一个真实排查链路镜像明明很小但构建还是很慢完整记录一个我处理过的排查过程看看思路是什么。现象是镜像已经优化到 250MB但docker build还是要六分钟。第一步看构建日志开头的上下文传输部分。发现Sending build context传输了 800MB比最终镜像还要大好几倍。查一下发现项目根目录里有个data/目录里面放了几个 GB 的训练数据被.dockerignore忽略了但构建上下文是从 Dockerfile 所在目录向上找的实际打包的是更上层的目录那个目录里有node_modules和target。把构建上下文的基准目录调整到项目根目录并且补上.dockerignore上下文传输降到 80MB。第二步检查层缓存命中。日志里发现每次构建都在重新执行npm ci而且前面那一层显示COPY . .变化频繁。确认是源码目录下每次构建都有生成物写在里面比如.env或者dist导致这一层永不命中。调整.dockerignore把这些目录排除之后npm ci开始稳定命中缓存。第三步确认基础镜像版本。发现 Dockerfile 里写的是node:18这种宽 tag每次构建都会重新解析镜像版本而且仓库如果有更新层缓存也会失效。改成锁定 digest 之后构建时间稳定在了两分钟以内。这个案例想说明的其实是镜像体积小不代表构建快构建慢的根因经常在上下文、缓存、版本漂移这三件事上排查时按上下文 → 层缓存 → 基础镜像版本这个顺序来基本不会漏。5.4 Docker 权限错误与守护进程启动问题镜像优化过程中也会碰到环境层面的问题最常见的是 Docker 权限报错。执行docker build时提示permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock通常是因为当前用户不在docker组里。sudo usermod -aG docker $USER执行完这条命令要重新登录才能生效。如果连守护进程都起不来比如failed to start docker application container engine这类提示先别急着重装去检查/etc/docker/daemon.json有没有语法错误尤其是刚改完 registry mirror 之后。也可以直接看 dockerd 的日志定位具体原因。这类问题虽然不是镜像优化本身但一卡就是大半天值得提前避开。6. 生产可落地的镜像优化工作流我最后留下的几条习惯整套优化做完之后我把流程沉淀成了固定习惯。第一条是区分开发环境和生产环境。开发阶段我会特意保留调试能力比如完整的 shell 和开发依赖生产镜像才追求最小体积和最小攻击面。这两种诉求本身存在冲突用 docker compose 的target参数把它们放在同一个 Dockerfile 里兼顾两边的体验。第二条是 CI 里用足构建缓存。BuildKit 可以把构建缓存导出成 inline 元数据或者单独的缓存镜像后续构建直接复用。具体做法是在 CI 里启用docker build --cache-from参数或者用 Buildx 的--cache-to/--cache-from指定镜像仓库作为缓存后端。实践下来CI 构建时间能再省三分之一到一半。前提是 Dockerfile 里层的顺序和 .dockerignore 已经优化到位否则缓存命中率提不上来。第三条是对基础镜像做版本管理。团队内部维护一个基础镜像清单统一到几个固定 tag 上并定期用安全扫描工具检查漏洞。镜像体积缩小不等于没有安全风险依赖层和运行层里依然可能有 CVE 需要跟进。多阶段构建减少了攻击面但最终还是要靠扫描和重建来保证生产环境健康。最后分享一个我个人的小习惯每次改完 Dockerfile我都会跑一遍docker history image看看最终镜像的层列表确认哪些大块头不该出现。这个命令很简单但对理解镜像到底装了什么帮助非常大。镜像优化不是一个一次性动作它更像是给整个团队建立的一种交付习惯——你的镜像干净了CI 快了生产环境占用的空间小了排障也变简单了。这些收益加在一起远比一开始花在重写 Dockerfile 上的那几个小时值得。
返回列表