十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Docker多阶段构建实战:将镜像体积从700MB优化到80MB

Docker多阶段构建实战:将镜像体积从700MB优化到80MB 先把话说在前面我是那种一开始并不怎么在意镜像体积的人总觉得能跑就行。直到公司一台只有 20GB 磁盘的测试机被一堆镜像塞满CI 开始频繁报 no space left on device我才认真把Docker 多阶段构建捡起来研究。回头看过往写的 Dockerfile确实有点惭愧——一个 Java 服务镜像动不动就 1GB 往上里面既有编译工具链又有源码拷贝甚至还有临时下载的依赖包全是给最后一次 RUN 背锅的垃圾层。这篇内容不是什么官方文档翻译而是我自己从“能用”走到“够用”再走到“好用”的完整记录。核心会讲清楚多阶段构建到底解决了什么问题、语法层的几个关键动作是怎么回事再拿一个常见项目手把手演示怎么把镜像从动辄几百 MB 做到只剩必需运行时最后把实战里踩过的坑全部摊开说。适合正在被镜像体积、构建速度、部署效率困扰的同学尤其是刚开始搭 CI/CD、想优化交付物的后端和运维朋友。1. 一个 Dockerfile 为什么会把镜像撑到几个 GB1.1 先看看单阶段构建问题出在哪很多新手写 Dockerfile 的第一感觉是把所有步骤全塞进一个 FROM 里能构建成功就算完事。比如构建一个 Java 服务下意识写出来的 Dockerfile 长这样FROM maven:3.8-openjdk-11 WORKDIR /app COPY . . RUN mvn clean package -DskipTests EXPOSE 8080 CMD [java, -jar, target/my-app.jar]这个写法完全能用但它的问题非常隐蔽。Docker 镜像本质上是一堆只读层的叠加每一条 RUN、COPY、ADD 指令都会生成一个新层。上面这个例子里maven:3.8-openjdk-11 这个基础镜像本身就包含了完整 JDK 和 Maven 工具链体积通常在 500MB 以上接着你把项目源码打了进去然后 RUN mvn package 的时候 Maven 又会把整个项目的所有依赖包下载到镜像层里。也就是说最终镜像里不仅有最终要跑的 jar 包还有完整 JDK、Maven 工具链、项目全部源码、本地仓库里所有 jar 依赖、临时编译产物。这些全是运行时不需要的东西但 Docker 不会自动帮你清理因为每一层都被记录在镜像元数据里。这就像你搬家后没有扔纸箱把所有家具连同包装纸箱、填充泡沫、安装工具全塞进了新家。居住没问题但房子越来越拥挤搬运越来越慢。1.2 多阶段构建到底改了什么多阶段构建的核心思路特别朴素允许一个 Dockerfile 里有多个 FROM但最终镜像只保留最后一个 FROM 以及它之后的指令生成的内容。前面的阶段可以理解成“加工车间”最后一个阶段才是“成品展示区”。# 阶段1构建 FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY . . RUN mvn clean package -DskipTests # 阶段2运行 FROM eclipse-temurin:11-jre WORKDIR /app COPY --frombuilder /app/target/my-app.jar app.jar EXPOSE 8080 CMD [java, -jar, app.jar]这样改造后最终镜像只有 eclipse-temurin:11-jre 基础层和从构建阶段拷贝出来的 jar 包层。JDK、Maven、源码、依赖仓库这些全部留在第一个阶段不会进入最终产物。我刚开始看到这个写法时有个疑问第一个阶段的镜像难道不会被一起推送和拉取吗答案是只有最后一个 FROM 产生的镜像会被标记和推送中间阶段只是构建时的临时缓存最终镜像引用的层只来自最后一条 FROM 及其后续指令。这也是多阶段构建最大的价值不引入额外工具不用写一堆清理脚本就能实现“构建环境隔离 产物精准导出”。用生活里的例子来类比单阶段构建是让你带着整个厨房去做一道菜菜做好了整个厨房也搬到餐厅里了多阶段构建是你在后厨做好菜端到前厅的只有那一道菜。2. 多阶段构建语法拆解FROM AS 和 COPY --from 怎么用2.1 先记住这三个关键语法多阶段构建语法本身不复杂真正需要记住的核心动作就三个定义阶段别名、跨阶段拷贝、阶段内构建指令。FROM node:18-alpine AS build-stage FROM nginx:alpine AS runtime COPY --frombuild-stage /app/dist /usr/share/nginx/html第一个是FROM xxx AS yyy给当前阶段起个名字方便后面引用。注意这个别名只在 Dockerfile 内部有效不会跑到镜像元数据里。第二阶段可以用COPY --frombuild-stage从构建阶段拷贝产物。第二个是COPY --from阶段名 /源路径 /目标路径。很多人第一次写会忘了--from,结果 Docker 尝试从构建上下文里找文件报 file not found然后开始怀疑人生。这里有个重要区分--from后面既可以是同一个 Dockerfile 里的阶段别名也可以是一个外部镜像名比如COPY --fromnginx:alpine /usr/share/nginx/html /html这在做静态资源初始化时非常有用。第三个是外部构建参数 ARG 的作用范围。默认情况下在 FROM 之前定义的 ARG 只能用在 FROM 指令里不会自动传给后面的阶段。想在第二个阶段用同一个参数必须在那个阶段里重新声明 ARG。这个细节我在实际项目里踩过一次后面常见问题部分会细说。2.2 为什么用多阶段而不是直接在单阶段里 rm -rf有人可能会问那我在单阶段构建最后把不需要的文件删掉不就行了比如 RUN mvn package 之后再 RUN rm -rf /root/.m2 这样。理论上是可行的但效果远不如多阶段构建。原因出在 Docker 的层机制上——即使你在后面的 RUN 里删除了文件前面 RUN 层里那些文件仍然存在。Docker 的文件系统是分层的删除操作只是在更上层做一个 whiteout 标记底层数据依然占据实际空间。所以单阶段里就算你没日没夜地清理镜像体积还是不会有本质变化除非你把所有操作压缩在同一个 RUN 指令里完成并且每一条会产生大文件的指令都成对清理。这种写法维护成本极高稍不留神就漏掉某个临时文件。多阶段构建从机制上绕开了这个问题不要的层直接不进入最终镜像干净利落。除此之外多阶段构建还顺带解决了两个单阶段方案很难处理的问题。一是安全。单阶段构建后最终镜像里会保留构建工具链一旦镜像被推送到了外部仓库等于把完整的编译环境和源码暴露给拿到镜像的人。多阶段构建后最终运行镜像里只有精简运行时攻击面小得多。用我之前写的一个 Go 项目测试过把镜像拖下来后用 dive 扫图层多阶段构建后的镜像里真的只有一个编译好的二进制什么编译器、源码、环境变量残留全都找不到。二是构建环境的灵活性。每个阶段可以选择不同的基础镜像不会互相干扰。比如前端项目可以用 node 镜像构建静态资源然后用 nginx 镜像做运行环境Python 项目可以在构建阶段装一堆编译依赖运行阶段只留一个干净的 python 镜像。这种不同阶段各取所需的方式单阶段完全做不到。2.3 ARG、ENV 和多阶段的作用域陷阱ARG 和 ENV 在多阶段构建里有个比较绕的地方。ARG 的作用范围默认是它所在 FROM 之后到下一个 FROM 之前而且每进入一个新阶段之前阶段的 ARG 就全失效了。想要在多个阶段共享一个参数必须在每个阶段里重新声明ARG VERSION1.0.0 FROM maven:3.8-openjdk-11 AS builder ARG VERSION RUN echo build version is $VERSION FROM eclipse-temurin:11-jre ARG VERSION RUN echo run version is $VERSIONENV 则不一样。ENV 定义的变量会作为环境变量写进该阶段的镜像层并且会保留到最终运行容器里。在多阶段构建里如果你在构建阶段设置了 ENV而最终运行阶段没有重新设置那么这些 ENV 不会自动带入最终阶段因为不同阶段的文件系统是相互独立的。这个特性有时会带来安全问题。比如你在构建阶段设置了一个数据库密码的 ENV虽然最终镜像没有包含构建阶段但如果构建阶段的缓存层被某些工具扫描到依然存在信息泄露风险。所以我的建议是需要传给最终容器的配置尽量通过命令行参数--env或--env-file在运行时传入不要把敏感信息写死在 Dockerfile 里。3. 实战把一个 Spring Boot 镜像从 700MB 降到 180MB3.1 原始构建方案与问题确认纸上谈兵不够这次我拿一个实际项目来做演示。项目是一个典型的 Spring Boot 2.7 应用用 Maven 构建最终产物是一个 fat jar。第一次写的 Dockerfile 就是最朴素的单阶段方案FROM maven:3.8-openjdk-11 WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests EXPOSE 8080 CMD [java, -jar, target/demo-0.0.1-SNAPSHOT.jar]构建完成后我执行了docker images | grep demo镜像体积为 712MB。这个数字其实不夸张maven:3.8-openjdk-11 基础镜像本身就 400 多 MB加上 Maven 拉下来的依赖和项目 jar700MB 很正常。问题不仅体现在磁盘占用上。把 700MB 的镜像推送到镜像仓库每次代码变更都要重新上传一遍这些层部署新环境时要从仓库拉取容器平台节点数量多时网络开销会成倍放大。我之前在内部交流时听到一个案例某团队把镜像优化后发布耗时从 8 分钟降到 90 秒节点磁盘使用率也大幅下降。这就是多阶段构建直接的业务收益。3.2 改造为多阶段构建的完整过程接下来把它改造成多阶段构建。我在项目里把 pom.xml 和源码分开拷贝是为了利用 Docker 的构建缓存只要 pom.xml 没变依赖下载这一步就可以命中缓存不用每次构建都重新拉一遍依赖。然后新增一个 runtime 阶段选择轻量 JRE 镜像作为基底用COPY --from把 jar 拷贝过去# 构建阶段 FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests # 运行阶段 FROM eclipse-temurin:11-jre WORKDIR /app COPY --frombuilder /app/target/demo-0.0.1-SNAPSHOT.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]这个 Dockerfile 和第一版最本质的区别就是第一版最终镜像包含 Maven 和 JDK第二版最终镜像只包含 JRE 运行时和 app.jar。构建完成后我在宿主机上查看镜像信息docker images | grep demo docker history demo:latest改造后镜像体积为 183MB。为确认最终镜像里没有残留编译器我执行了docker run --rm demo:latest sh -c which javac输出显示 javac 不存在JDK 工具链确实没有带入。从 712MB 降到 183MB节省了约 74% 的体积效果非常直接。3.3 继续瘦身Spring Boot 分层 jar 和 jlink 定制 JRE如果你觉得 183MB 还不够小Spring Boot 2.7 可以启用分层 jar 功能配合 Dockerfile 的 COPY 指令按层拷贝# pom.xml plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration layers enabledtrue/enabled /layers /configuration /plugin然后 Dockerfile 可以这样写FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:11-jre AS extractor WORKDIR /app COPY --frombuilder /app/target/demo-0.0.1-SNAPSHOT.jar app.jar RUN java -Djarmodelayertools -jar app.jar extract FROM eclipse-temurin:11-jre WORKDIR /app COPY --fromextractor /app/dependencies/ ./ COPY --fromextractor /app/spring-boot-loader/ ./ COPY --fromextractor /app/snapshot-dependencies/ ./ COPY --fromextractor /app/application/ ./ ENTRYPOINT [java, org.springframework.boot.loader.JarLauncher]分层的主要收益不只是体积而是缓存命中率。Spring Boot 的 fat jar 其实分了三块依赖库、Spring Boot 自身 loader、业务代码。把依赖单独复制到一层以后业务代码改了依赖层可以从缓存直接复用不用整个镜像重新推送。这在 CI 流水线上带来的构建时间节省比体积更值钱。再往下走一步就是 jlink 定制 JRE。JDK 9 自带 jlink可以只保留运行时需要的模块。比如一个 Spring Boot 应用可能只需要 java.base、java.sql、java.naming 等几十个模块裁剪后 JRE 可以做到 40MB 左右。Dockerfile 构建阶段变成FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:11-jre AS jre-build RUN jlink --add-modules java.base,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument \ --strip-debug --no-man-pages --no-header-files \ --compress2 --output /jre FROM debian:buster-slim COPY --frombuilder /app/target/demo-0.0.1-SNAPSHOT.jar app.jar COPY --fromjre-build /jre /jre ENTRYPOINT [/jre/bin/java, -jar, app.jar]这样处理后Java 应用的镜像体积可以压到 80MB 左右。如果你的项目里用不到某些模块可以再砍但注意模块列表需要根据应用实际使用情况来恒定缺失模块会在运行时直接 NoClassDefFoundError排查起来比较费神建议先在生产环境跑一轮完整回归测试。3.4 不同语言生态的多阶段构建模式不止 Java多阶段构建在 Go、Node.js、Python 生态里都是标配操作我在这里整理了几个最常用的模板方便对照你的项目背景直接套用。先看 Go 项目。Go 的静态编译特性让多阶段构建收益最大最终产物甚至可以做到极小的体积FROM golang:1.20 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -o api ./cmd/server FROM alpine:3.18 RUN apk add --no-cache ca-certificates tzdata COPY --frombuilder /app/api /usr/local/bin/api EXPOSE 8080 ENTRYPOINT [api]CGO_ENABLED0 很关键它强制 Go 使用静态链接这样运行阶段的基础镜像可以选不含 glibc 的 alpine整体体积大概 20MB 左右。如果你的应用依赖 cgo 或外部 C 库就不能开这个开关此时基础镜像建议换成一个包含 glibc 的发行版镜像。再看 Node.js 项目。前端构建场景可能是多阶段最频繁的使用场景之一构建阶段用 node 做打包运行阶段换 nginx 托管静态文件FROM node:18-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --frombuild /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80这个模式里有个小优化先拷贝 package.json 和锁文件执行 npm ci 之后再把整个项目拷进来。因为 npm ci 的耗时最长只要 package.json 没变这个阶段就能从构建缓存直接恢复不需要每次 CI 都重新下载依赖。Python 的 pip、Go 的 mod download、Java 的 dependency:go-offline 也都是同一个思路。4. 多阶段构建的常见坑和排查思路4.1 COPY --from 找不到目标文件这是多阶段构建最常踩的坑。现象是构建时报错COPY failed: stat /var/lib/docker/tmp/docker-builder123456/xxx: no such file or directory大部分原因都是 COPY 路径写错了。构建阶段的绝对路径总是以构建阶段里的 WORKDIR 为基准的如果你在 builder 阶段没设置 WORKDIR那直接用 COPY . . 把项目拷贝到根目录后面你再想 COPY /app/target/xxx 自然是找不到的。我的习惯是每个阶段第一行就明确写 WORKDIR这样 COPY --from 里面的路径一眼就能对上。还有一种情况是源路径写成了绝对路径但实际产物在相对路径下。比如COPY --frombuilder target/app.jar /app.jarDocker 解析这个路径时是在 builder 阶段的 WORKDIR 基础上拼接的。如果 WORKDIR 是 /app那么实际找的是 /app/target/app.jar如果构建阶段也在 /app那没问题如果构建阶段的 WORKDIR 不是 /app就会报错。检查要点就一句话源路径等效于“在源阶段里进入 WORKDIR 后看到的路径”。4.2 ARG 作用域隔离导致变量用不了有次我在构建脚本里定义了一个通用参数想同时在多个阶段使用但第二阶段无论如何都拿不到值。查了一圈文档才想起来——ARG 只在当前阶段有效进入下一个 FROM 后变量就没了。ARG JAR_NAMEapp.jar FROM maven:3.8-openjdk-11 AS builder # 这里必须重新声明否则 $JAR_NAME 是空的 ARG JAR_NAME RUN ... FROM eclipse-temurin:11-jre ARG JAR_NAME COPY --frombuilder /app/target/${JAR_NAME} app.jar如果你的构建参数比较固定最简单的做法是每个阶段顶格加一行ARG声明。如果参数特别多可以写一个公共注释段提醒自己或者用.env文件搭配docker build --build-arg-file统一管理。注意--build-arg-file只在较新的 Docker 版本里支持老版本只能在命令后面手动挂多个--build-arg。4.3 缓存失效导致每次全量构建多阶段构建配合缓存策略能大幅减少构建时间。原则是“把改动频率低的内容尽量放在前面把改动频率高的内容放在后面”。最典型的错误是把COPY . .放在RUN mvn dependency:go-offline之前导致任何源码改动都会让整个依赖层缓存失效。我见过很多团队发版慢仔细看 Dockerfile 全是一股脑 COPY 所有文件进去然后再下载依赖。正确顺序我已经在实战案例里展示过先拷贝 pom.xml/package.json 这类描述文件跑依赖下载再把源码拷贝进去跑真正的构建。需要多阶段引用外部镜像做COPY --fromnginx:alpine /usr/share/nginx/html /html这类操作时也要注意这个缓存原则把不变的基础镜像内容放在稳定层。另外BuildKit 提供了更细粒度的缓存挂载特性下一节会展开讲。4.4 时区、证书、非 root 用户问题经常有朋友反馈镜像从多阶段构建改成精简基础镜像后应用启动报时区不对或者 HTTPS 调用报证书问题。这通常是因为最终阶段选的镜像太小了像 alpine 默认就不带时区数据和 CA 证书。解决方案是在运行阶段安装或拷贝这些东西FROM alpine:3.18 RUN apk add --no-cache ca-certificates tzdata ENV TZAsia/Shanghai COPY --frombuilder /app/service /usr/local/bin/service如果是非 root 部署环境Kubernetes 的 securityContext 或企业的运行时规范常要求还需要在 Dockerfile 里创建普通用户FROM debian:buster-slim RUN useradd -r -u 1001 appuser USER appuser COPY --chownappuser:appuser --frombuilder /app/service /usr/local/bin/service注意--chown可以放在 COPY 指令里避免落盘文件的属主不对导致权限拒绝。生产环境里因为容器内非 root 用户不能写工作目录而崩溃的情况我遇到太多次了。4.5 热词场景彩蛋镜像下载慢和 Windows 环境问题近几年很多人被镜像下载慢困扰这个不光是网络问题也和镜像仓库配置有关。如果你使用的是默认的公共镜像仓库拉取基础镜像确实可能很慢可以考虑在 Docker 配置里设置一个位于你网络可达区域的镜像源地址。注意不要使用来源不明或存在安全隐患的加速地址优先用你所在云厂商官方提供的镜像仓库或搭建自建的 registry 做内网分发。搭建内部镜像仓库后可以把基础镜像提前拉到内网让各个构建节点统一从内网拉取速度稳定且可控这也是企业落地 Docker 的常规路径。另外在 Windows 上Dockerfile 文件本身的行尾符会导致 Linux 环境下执行脚本时报/bin/sh^M: bad interpreter。解决办法是让 Git 在 checkout 时自动转换行尾项目根目录加一个.gitattributes*.sh text eollf Dockerfile text eollf这个坑只坑新手的概率很高老手也容易在团队协作时被坑因为 windows 和 mac 混用的仓库经常出现行尾不一致。5. 进阶玩法缓存、多架构与生产环境落地5.1 BuildKit 高级语法RUN --mounttypecache从 Docker 18.09 开始BuildKit 成为默认构建引擎带来了一系列增强语法。其中最常用的就是RUN --mounttypecache。它可以给某些目录挂一个持久化缓存这个缓存不会写进镜像层但会在二次构建时命中。Maven 构建的典型用法是缓存本地仓库FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN --mounttypecache,target/root/.m2 mvn dependency:go-offline COPY src ./src RUN --mounttypecache,target/root/.m2 mvn clean package -DskipTests这样即使你换了代码只要依赖坐标没变Maven 的依赖仍然从 cache mount 里复用不用重新下载。npm、pip、Go mod 同理。不过用这个语法时记得开启 BuildKitDOCKER_BUILDKIT1 docker build较新版本已默认开启。如果你还在用老版本的 Docker Engine可能需要花点时间升级一下构建环境。5.2 多架构镜像构建多阶段构建和 buildx 一起使用可以一次性产出 amd64、arm64、甚至龙芯等架构的镜像。核心是用--platform配合 Dockerfile 里的 TARGETARCH 参数FROM maven:3.8-openjdk-11 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM --platform$TARGETPLATFORM eclipse-temurin:11-jre WORKDIR /app COPY --frombuilder /app/target/demo-0.0.1-SNAPSHOT.jar app.jar ENTRYPOINT [java, -jar, app.jar]构建命令变成docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.com/demo:1.0.0 --push .这里主要记住一点不同架构的编译产物是不同的所以在构建阶段务必要让编译器知道目标架构。Go 编译时通过GOARCH编译Java 这种字节码语言相对省心但遇到依赖了 native 库的情况比如一些加密组件、图像处理库就一定要在构建阶段传入TARGETARCH之类的平台参数否则构建阶段按默认架构编译产物拿到目标架构上根本跑不起来。5.3 生产环境里多阶段构建和 CI/CD 怎么配合多阶段构建不是只能在本地命令行里用在 CI/CD 流水线里的收益更大。我在公司内部落地时流水线大致是这样一套逻辑代码 push 到分支触发流水线执行docker build。构建阶段和运行阶段做了分离依赖层用 cache mount 命中常规改动平均构建时间从 6 分钟降到 2 分钟左右。镜像构建完成后先推送到内部仓库再进入发布流程。这里有一个非常值得做的改造把 Dockerfile 拆成“基础依赖阶段”“编译阶段”“运行阶段”并让基础依赖阶段的镜像预先构建好。比如前端项目可以单独做一个 node_modules 基础镜像后端项目可以预装 Maven 依赖。发布时直接基于这些预构建阶段做增量构建速度会非常快。还有一点容易被忽略最终镜像里的内容越少安全暴露面越小。我曾对优化前后的镜像跑过漏洞扫描单阶段镜像扫描出的 High 级别漏洞主要集中在构建工具链带进来的组件上多阶段后的运行镜像漏洞数量显著减少。体积变小只是最表面的好处减少攻击面才是更深层的收益。最后分享一个生产落地时很实用的小技巧在多阶段的运行阶段里加一个健康检查指令。虽然HEALTHCHECK不属于多阶段构建的核心语法但它在容器编排平台里非常有用能给负载均衡和自动重启提供判断依据。比如我的 Spring Boot 服务运行阶段会加HEALTHCHECK --interval30s --timeout3s --retries3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1不过注意如果最终镜像里没有 curl就要先安装或者改用 wget、Java 自带方式否则健康检查永远失败。我踩过一轮坑后总结的经验是先确认运行镜像里有哪些可用的探测工具再决定 HEALTHCHECK 到底怎么写别想当然用 curl。从 712MB 到 183MB再到 80MB 左右这一路优化下来我最大的体会是镜像体积变小不是目的而是结果——构建更快、部署更稳、攻击面更小、磁盘压力更小这些才是多阶段构建真正带来的价值。而且它不需要引入额外的构建工具或复杂的脚本只要你愿意调整 Dockerfile 的写法收益几乎是立刻兑现的。如果你手头还有那种一个 FROM 写到尾的项目我建议你现在就打开看一眼改成多阶段之后的惊喜会来得特别快。
返回列表