☰
Dockerfile生产优化:镜像分层、构建缓存与多阶段构建
2026/10/26 5:15:22 网站建设 项目流程

上周帮一个朋友排查构建脚本,同一个代码仓库、同一台机器,他构建出来的镜像 1.2G,我这边构建出来 340M,容器跑起来功能完全一致。他第一反应是基础镜像选得不一样,结果两边的FROM写的是同一行。差别全在中间那二十几行 Dockerfile 里:合并与不合并的RUN、有没有清包管理器缓存、COPY把哪些不该进镜像的目录也塞了进去、多阶段构建有没有真正用上。这类问题在团队里极其普遍——Dockerfile 的语法十分钟就能翻完,但把它写到生产可用,靠的不是背指令,而是对分层、缓存、构建上下文这三个概念的直觉。

这篇就围绕 Dockerfile 展开,从指令背后的分层机制讲到多阶段构建的落地写法,再到构建缓存突然失效时怎么一层层定位。适合已经能跑通docker build、但镜像体积和构建时间一直不太理想的人,也适合刚开始接触容器化、想把地基打扎实的同学。示例以一个典型的前后端项目为背景,你可以直接对着自己的仓库改。

1. 三个镜像体积差四倍:问题从来不出在语法上

1.1 一个几乎每个团队都遇到过的现场

某项目组做过一次小统计:让四位同事各自为同一个服务写 Dockerfile,都能构建成功、都能跑起来,最后镜像体积分别是 210MB、480MB、760MB 和 1.3GB。这四个人写的语法全对,没有一条指令报错。体积差距的来源分别是:基础镜像选了带完整工具链的版本、RUN没有合并导致中间产物被固化进层、构建上下文把node_modules和.git一起传了进去、以及没有使用多阶段构建。

这件事说明一个道理:Dockerfile 的"正确"有两个层次。第一层是能构建成功、容器能启动,这叫语法正确;第二层是镜像尽可能小、构建尽可能快、运行时尽可能安全,这叫工程正确。绝大多数教程只教第一层,而真正吃经验的是第二层。

1.2 镜像的本质是一摞只读层的叠加

要理解为什么中间产物会"固化",得先搞清楚镜像的构成。一个镜像由若干只读层(layer)自下而上叠加而成,每条会修改文件系统的指令(RUN、COPY、ADD)都会生成一个新层。关键是:层一旦生成就不可修改。

这意味着如果你这么写:

RUN apt-get update RUN apt-get install -y curl RUN apt-get clean

第三条apt-get clean确实删掉了缓存文件,但删除动作发生在第四层,而缓存文件实实在在躺在第二层里。镜像体积按所有层的总和计算,第四层的"删除"只是一个白障(whiteout)标记,并不会让第二层瘦下来。正确写法是把更新、安装、清理塞进同一条RUN:

RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*

这个道理用生活类比很好理解:往笔记本上写字,写错了不能擦,只能贴一张修正贴。修正贴看着是空白,但底下的字还在,纸的总厚度只增不减。

1.3 "能跑就行"的代价会在什么时候显现

有人会说,镜像大点就大点,反正服务器硬盘够。真正的代价不在硬盘,而在三个地方。第一是分发时间:每次滚动发布,每个节点都要拉一遍镜像,1.3G 和 340M 在几十台节点的规模下差的是分钟级;第二是攻击面:镜像里多出来的编译器、包管理器缓存、甚至.git目录,都是潜在的暴露点;第三是构建反馈速度:一次构建八分钟,谁都不愿意在本地频繁验证,团队会退化成"改完直接推到 CI 上碰运气"。

我在实际项目里的判断标准是:一个纯运行时镜像(比如 Java 或 Node 服务),如果超过 500MB,就要逐层盘一次docker history,基本总能找到一层是多余的。

2. FROM 那一行,决定了后面所有优化的天花板

2.1 基础镜像的三档选择与各自适用面

FROM是整份 Dockerfile 的地基,选错了后面再怎么精简都事倍功半。常见的选择大致分三档:

类型典型特征体积量级适用场景
完整发行版镜像带包管理器、常用命令行工具数百 MB构建阶段、需要在线调试的环境
精简发行版镜像去掉文档、部分工具,保留包管理器几十到一百多 MB生产运行时,需要装少量系统依赖
极简/静态镜像无包管理器、无 shell 或仅极简 shell几 MB 到几十 MB静态编译产物、Go/Rust 等自包含二进制

新手最容易犯的错是用完整发行版做运行时基础镜像,因为"装东西方便"。但运行时的容器里其实不需要gcc、不需要vim、不需要curl——真要排查问题,用docker exec进容器或者临时跑一个调试容器都行,没必要把它们长期塞在镜像里。构建阶段用完整镜像、运行阶段用精简镜像,这正是多阶段构建要解决的问题,后面第 6 节会详细拆。

2.2 tag 漂移:昨天能构建,今天突然失败

FROM node:18这种写法很省事,但它指向的是一个会移动的标签。上游哪天推送了新补丁版本,你下次构建就可能在完全没改代码的情况下出现依赖行为变化。我遇到过最典型的一次是某个小版本更新后,默认的 OpenSSL 配置变了,导致一个 TLS 握手相关的测试开始随机失败,排查了半天才发现是基础镜像变了。

稳妥的做法有两个层次。日常开发可以固定到次版本号,比如node:18.19,比node:18稳。对可复现性要求高的生产构建,直接固定到 digest:

FROM node:18.19-bookworm@sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

digest 长得丑、换版本时麻烦,换来的是"今天构建和三个月后构建,拉到的字节完全一致"。什么时候需要这个级别的严格?合规审计、线上事故回溯、多团队共享基础镜像这几种场景,我认为是必须的。

2.3 一条容易被忽略的构建平台参数

在多架构环境下,FROM的解析结果还跟构建平台有关。如果你的开发机是 ARM 架构、部署目标是 x86 服务器,不显式指定平台时,构建出来的镜像架构可能跟目标环境对不上,表现为部署时报 exec format error。这时需要在FROM上加参数:

FROM --platform=linux/amd64 eclipse-temurin:17-jre

更常见的做法是在构建命令里指定--platform,让整份 Dockerfile 保持一致,同时只解析一次基础镜像。需要注意交叉构建时,如果RUN里有编译动作,性能会明显下降,通常要配合构建器(builder)来加速,这部分属于构建环境的配置范畴,本文不展开,但值得知道有这个性能拐点。

3. RUN 的分层账本:合并、清理与缓存命中的连锁反应

3.1 每条 RUN 都是一次独立的事务

RUN是所有指令里最"重"的一条,因为它在构建时真正执行命令,并把整个文件系统的变化打包成新层。前面说过层不可改,所以要建立一个心智模型:每写一条RUN,就当自己开了一个新的、永久保存的快照。基于这个模型,几条实操原则自然就出来了。

第一,相关的操作尽量合并成一条。把apt-get update和install放在不同的RUN里是经典错误:缓存命中的时候,update那层可能用的是几周前的索引,导致安装到过时的包甚至安装失败(索引里的版本已经下架)。合并之后,两者同生共死,要么都命中,要么都重跑。

第二,长命令用反斜杠续行,配合set -eux之类的开关,让失败尽早暴露:

SHELL ["/bin/bash", "-o", "pipefail", "-c"] RUN set -eux; \ groupadd -r app; \ useradd -r -g app -d /app -s /sbin/nologin app; \ mkdir -p /app; \ chown -R app:app /app

pipefail这一项特别值得加。默认情况下cmd1 | cmd2的退出码只看cmd2,前面管道挂了也不会让构建失败,问题会被默默地"构建成功"掩盖掉。

3.2 包管理器清理的正确姿势,逐个说清

不同语言的包管理器,清理方式差异很大,我按实际用到的顺序列一下,都是在同一条RUN里收尾:

  • Debian/Ubuntu 系:rm -rf /var/lib/apt/lists/*,这是收益最大的一条,通常能省几十 MB。apt-get clean只清/var/cache/apt/archives,别只写一个就以为完事。
  • Alpine:apk add --no-cache,参数本身就抑制了索引缓存,比事后清理更干净。
  • Python:pip install --no-cache-dir,不加这个参数,下载的 wheel 会留在用户缓存目录里。
  • Node:npm ci --omit=dev之外,配合npm cache clean --force,或者直接用多阶段构建把依赖单独装在一个阶段。
  • Maven/Gradle:把本地仓库目录指向可丢弃的位置,或者干脆在多阶段构建里让构建层留在前一阶段。

这里有个细节:--no-install-recommends这个参数在 Debian 系上值得默认加上。很多推荐包(比如某些字体、文档工具)会被顺带装上,体积往往比主包还大,而运行时多数情况下用不到。

3.3 缓存失效是从第一条不匹配的指令开始的

构建缓存的工作机制是:从上往下逐条比对,每条指令的"指纹"包括指令文本本身,以及它依赖的上一层的内容。一旦某条指令的指纹不匹配,它和它之后的所有指令都会重新执行。

这个机制的实践含义是:把变化频率低的部分放前面,变化频率高的放后面。一个典型的 Node 项目,正确的顺序是先COPY package.json package-lock.json再RUN npm ci,最后才COPY . .。这样改业务代码时,依赖安装那层始终命中,构建时间能从几分钟降到十几秒。反过来写,每次改一行代码都要重装依赖,团队会疯。

有一次我改了一份 Dockerfile,把COPY挪到了RUN前面,自以为顺序更"自然",结果构建时间从 40 秒涨到 6 分钟,CI 上的缓存命中率从 90% 掉到接近零。这类顺序问题不看构建日志里的 CACHED 标记根本发现不了,我后来的习惯是构建完扫一眼输出,确认该命中的层都标了 CACHED。

4. COPY、ADD 与 .dockerignore:最容易吃亏的地方

4.1 构建上下文到底传了什么

执行docker build时,客户端会把指定的目录(默认是当前目录)打包发给构建器,这个目录就是构建上下文。很多人以为只有被COPY引用的文件才会传,实际上整个上下文都会被打包,除非被.dockerignore排除。

后果很直接:仓库里有一个 800MB 的测试数据集、一堆.git历史、本地虚拟环境目录,全都会被打进传输包。我在一次构建里观察过,光是传输上下文就花了 50 秒,而真正编译只花了 20 秒。更麻烦的是有些人把源码目录放在上级目录,docker build .直接扫了一遍家目录,那个体验相当酸爽。

所以第一条硬性建议:构建上下文目录尽量小、尽量干净。如果确实需要构建上下文之外的文件,用docker build -f单独指定 Dockerfile 的路径,而不是把上下文放大。

4.2 ADD 与 COPY 的边界:能用 COPY 就别用 ADD

ADD比COPY多了两个能力:自动解压本地 tar 包、支持 URL 作为源。这两个能力听起来方便,实际是坑:

  • 从 URL 拉取文件不会走构建缓存,且无法校验完整性,上游内容变了你也感知不到;
  • 自动解压行为对压缩格式有特定要求,某些格式不解压也不报错,容易造成"文件是有的但内容不对";
  • 对 tar 包解压后的权限、时间戳处理有隐式行为,排查起来很费劲。

我的经验是给团队定一条规则:只要不是必须,一律用COPY。需要下载文件就用RUN curl -fsSLO配合校验和,需要解压就显式tar -xf。多写一行命令,换来的是行为可预期。

另外COPY本身就支持--chown和--chmod,很多人不知道,还在 COPY 之后补一条RUN chown,白白多一层:

COPY --chown=app:app --chmod=755 ./entrypoint.sh /app/entrypoint.sh

4.3 一份可以直接抄的 .dockerignore 骨架

.dockerignore的语法跟.gitignore类似,但有两个区别要记住:!开头的例外规则在忽略规则之后生效;匹配是基于上下文根目录的相对路径。下面这份是我常用骨架,按项目类型增删:

# 版本控制 .git .gitignore # 本地依赖与构建产物 node_modules dist build target __pycache__ *.pyc .venv venv # 编辑器与系统文件 .idea .vscode .DS_Store # 日志与临时文件 *.log tmp .cache # 敏感文件(这一条最重要) .env .env.* *.pem *.key

最后那类敏感文件必须排除。我见过不止一次镜像里躺着.env,里面是数据库密码和第三方密钥,镜像推到私有仓库后被下游多个环境拉走,密钥的扩散面完全不可控。这种事一旦发生,只能全量轮换密钥,成本极高。

5. CMD 和 ENTRYPOINT:容器停不下来往往栽在这

5.1 shell 形式与 exec 形式,差的不只是一个方括号

CMD和ENTRYPOINT都有两种写法:shell 形式(CMD npm start)和 exec 形式(CMD ["npm","start"])。很多人觉得两者等价,其实差别巨大。

shell 形式实际执行的是/bin/sh -c "npm start",真正的应用进程是 shell 的子进程。这带来两个后果:一是 shell 成为容器的 1 号进程,它会吞掉大部分信号,docker stop发送的 SIGTERM 到不了应用,只能等超时后 SIGKILL 强杀,表现为"停止容器要等十秒";二是应用收不到优雅退出信号,正在进行的事务、连接池、临时文件清理全都来不及做。

exec 形式下应用直接是 1 号进程,信号能收到,优雅退出才成立。所以结论很直白:除非确实需要 shell 展开变量或组合多条命令,一律用 exec 形式。需要环境变量展开的场合,可以套一层轻量入口脚本,并在脚本里用exec "$@"把 PID 1 交还给应用。

ENTRYPOINT ["/app/entrypoint.sh"] CMD ["node", "server.js"]

entrypoint.sh里最后一行必须是exec "$@",否则又回到子进程模式。

5.2 两者的组合关系,用一张表说清

CMD和ENTRYPOINT同时存在时的行为经常被记混,直接列表:

ENTRYPOINTCMD实际执行
无CMD ["a","b"]a b
ENTRYPOINT ["x"]无x
ENTRYPOINT ["x"]CMD ["a","b"]x a b
ENTRYPOINT ["x"]CMD ["a","b"],运行时docker run img c dx c d(CMD 被完全覆盖)
ENTRYPOINT ["x","a"]CMD ["b"]x a b

核心记忆点:CMD 提供默认参数,运行时命令行参数会整体替换 CMD,而 ENTRYPOINT 始终保留。所以做可配置的服务时,把不变的启动器和固定参数写进 ENTRYPOINT,把可变参数交给 CMD,用法最顺手。

还有一条容易踩的坑:同一个 Dockerfile 里写多个CMD,只有最后一个生效,前面的会被静默丢弃,构建时不报任何警告。这在我看过的存量脚本里出现过好几次,属于典型的"改了没生效但看不出来"。

5.3 1 号进程还要处理僵尸进程回收

除了信号,1 号进程还承担子进程回收的职责。普通应用没有实现这个逻辑,长期运行后可能积累僵尸进程。有两种解法:一是用docker run --init让一个极小的 init 进程接管(这个参数不改 Dockerfile,部署时加上就行);二是在镜像里装一个轻量 init 并作为 ENTRYPOINT,比如:

ENTRYPOINT ["/sbin/tini", "--", "/app/entrypoint.sh"]

我的实践是:短生命周期的批处理任务不折腾,长期驻留的服务统一加--init。这样 Dockerfile 保持干净,部署侧的编排文件里加一个参数就行。

6. 多阶段构建:把编译器留在上一站

6.1 一个前后端项目的最小可复现三段式

多阶段构建的核心思路是:一份 Dockerfile 里可以出现多个FROM,每个FROM开启一个新阶段,阶段之间用COPY --from=拷贝产物。只有最后一个阶段会成为最终镜像,前面的阶段全部丢弃。

以前端项目为例,一个能直接用的三段式长这样:

# 阶段一:装依赖(单独拆出来,最大化缓存命中) FROM node:18.19-bookworm AS deps WORKDIR /src COPY package.json package-lock.json ./ RUN npm ci --omit=dev # 阶段二:构建 FROM node:18.19-bookworm AS builder WORKDIR /src COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build # 阶段三:运行时 FROM nginx:1.25-alpine AS runtime COPY --from=builder /src/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80

把依赖安装单独拆成一个deps阶段,是因为构建工具和运行依赖可以并行准备,也因为后续如果要做依赖审计,可以单独针对这个阶段跑命令。有些团队会把它省掉,直接两段式,也没问题,取决于构建时长。

6.2 跨阶段拷贝的几个细节

COPY --from=的源写法有三种:阶段名、镜像名、阶段索引。用阶段名最清晰,改起来也方便。需要注意的是,跨阶段只能拷贝文件系统里的内容,不能拷环境变量。所以前一个阶段ENV设置的变量,在后一个阶段读不到,必须重新声明。这一点我在第一次用多阶段构建时踩过,在 builder 阶段设了NODE_ENV,运行时阶段发现它不存在。

另外,从外部镜像拷单个文件是很好用的技巧,比如只需要某个工具的二进制的场景:

COPY --from=busybox:1.36 /bin/busybox /usr/local/bin/busybox

但这种方式会让构建依赖一个你没在自己控制的基础镜像,版本稳定性要自己盯住,我不建议在核心业务镜像里滥用。

6.3 用缓存挂载把"反复下载"这件事彻底解决

多阶段构建解决了体积问题,但没解决"每次构建都要重新下载依赖"的问题——只要package-lock.json变了,npm ci就得从头跑。这时候可以用构建缓存的挂载能力,把包管理器的缓存目录挂进来:

RUN --mount=type=cache,target=/root/.npm \ npm ci --omit=dev

这个写法的意思是:这个目录的内容不进入镜像层,但构建器会保留它,下次构建时复用。效果是依赖没变时缓存直接命中,依赖变了也只下载增量的部分。我实测过一个中型项目,冷构建到热构建的时间差从 4 分多钟缩到 30 秒出头。

需要注意,缓存挂载依赖于较新的构建器,旧版本环境可能不支持,上生产前先在 CI 里验证一遍。另外缓存目录是共享的,多项目并发构建时可能互相干扰,给不同项目配不同的id参数就能隔离。

7. 构建缓存突然失效的完整排查链路

7.1 现象:什么都没改,构建却全量重跑

有一次 CI 上的构建时间从 90 秒突然涨到 7 分钟,代码没有任何相关改动。日志里npm ci那一层的 CACHED 标记消失了,但COPY package.json package-lock.json那层还是 CACHED。这个组合本身就是一个强信号:指令文本和输入文件都没变,那问题一定出在层的指纹之外——基础镜像。

7.2 逐层定位:从 docker history 到 build 输出

排查我一般按这个顺序走,从粗到细:

  1. 看构建日志,找到第一条没有标 CACHED 的指令。这是分界线,它之前的一切都是命中的,问题就在这条指令或其上的基础镜像。
  2. 对已有镜像跑docker history --no-trunc <image>,看每一层的大小和创建指令。如果发现某层体积异常大,基本能定位到是哪条RUN的锅。
  3. 用docker build --progress=plain重新构建一次,输出信息比默认的进度条详细得多,能看到每一层的缓存比对细节。
  4. 如果怀疑基础镜像变了,用docker pull拉一次并比对 digest,docker inspect --format='{{index .RepoDigests 0}}'能直接看到当前 digest。

那次排查的结果是:基础镜像的 tag 被上游重新推了一次,虽然 tag 名没变,但 digest 变了,导致它之后的所有层全部失效。这也印证了第 2 节说的,tag 是会移动的。

7.3 修复与验证:把"偶发"变成"可控"

修复分两步。短期是固定 digest,让构建恢复稳定;长期是调整流水线,把基础镜像更新变成一个显式的、有计划的动作,而不是被动接受。具体做法是在 CI 里加一个定时任务,每周检查一次基础镜像是否有新版本,有的话自动提一个变更申请,走正常评审流程。这样更新是可控的,出了问题的回滚路径也清晰。

验证方式很简单:连续两次构建,确认第二次的绝大多数层都是 CACHED,且整体耗时不高于预期阈值。我一般会在 CI 里加一条"热构建耗时"的检查,超过阈值就报警,能把缓存失效这类问题在第一时间抓出来。

7.4 其他几种常见的"缓存玄学"及其归因

除基础镜像外,还有几类高频原因值得记下来:

现象常见原因处理方式
只改了 README,全部重跑COPY . .位置太靠前把依赖安装提到 COPY 源码之前
依赖没变但安装层失效锁文件里有本地路径或时间戳变化检查锁文件生成方式,固定生成环境
同一份代码在两台机器上命中率不同上下文里有被忽略的差异文件统一.dockerignore,用--progress=plain对比
构建参数变化导致全量重跑ARG参与到了依赖安装指令中只把必需参数放前面,其余后置

最后一行值得展开:ARG的值参与缓存指纹计算。如果某个经常变动的构建参数(比如版本号)出现在靠前的指令里,那它一变,后面全废。解决方法要么是把参数的使用位置尽量后移,要么干脆在构建脚本层做替换,不进 Dockerfile。

8. 生产可用的 Dockerfile 还差哪几块

8.1 非 root 运行不是可选项

容器默认以 root 运行,这在很多团队里是默认状态,但它带来的风险是实打实的:一旦容器内有进程被利用,攻击者拿到的就是 root 权限,配合内核漏洞或挂载配置不当,可以扩散到宿主机。所以只要是生产镜像,创建专用用户并以它运行,应该成为硬性规范。

写法上要注意顺序:先创建用户和目录、改好属主,再切用户,最后拷业务文件时带上--chown:

RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app WORKDIR /app COPY --chown=app:app ./dist /app USER app

有个细节容易忽略:USER之后的所有指令(包括RUN)都以该用户身份执行,如果后面还有需要 root 的操作就会失败。所以USER要尽量放在靠后的位置。另外,某些基础镜像预置了非 root 用户(比如常见镜像里的node用户),可以直接USER node,省去自己创建。

8.2 健康检查与启动顺序的配合

HEALTHCHECK的作用是让编排系统知道容器内部是否真的可用,而不只是进程还活着。写法上要注意超时和重试次数的配合:

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

start-period这个参数很关键。应用启动需要时间,如果健康检查从容器启动那一刻就开始判定,会在应用还没起来时连续失败,导致容器被反复重启,形成启动风暴。设一个覆盖实际启动耗时的宽限期,能避免这种问题。我给的经验值是:start-period设成实测启动时间的 1.5 倍左右。

另外,健康检查的命令要足够轻。用curl检查/healthz是最常见的做法,但精简镜像里往往没有curl,只有wget,甚至什么都没有。这时候可以写一个用应用自身语言实现的探测脚本,或者干脆在编排层做健康检查,把 Dockerfile 保持干净。

8.3 时区、locale 与那几个"上线才发现"的小配置

有几个配置在开发环境完全无感,上了生产才暴露:日志时间戳差 8 小时、中文文件名乱码、数字格式化不符合预期。它们都源于容器内默认的时区和 locale 设置。

时区方面,最省事的做法是装tzdata并设置TZ环境变量。但tzdata本身有十几 MB,如果基础镜像支持,更好的做法是直接从宿主或镜像里拷贝时区文件:

ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

locale 方面,如果应用需要处理中文或做字符串排序,LANG和LC_ALL建议显式设置成C.UTF-8,比生成一套完整的 locale 便宜得多。这个设置对 Python 的默认编码、Java 的字符集推断都有实际影响,值得在项目模板里固定下来。

8.4 镜像元数据:为三个月后的自己留线索

LABEL这条指令很少有人认真写,但它在排查时价值很高。至少把源码版本、构建时间、对应仓库提交写进去:

LABEL org.opencontainers.image.revision="${GIT_COMMIT}" \ org.opencontainers.image.created="${BUILD_TIME}" \ org.opencontainers.image.source="https://example.invalid/repo"

这些值通过--build-arg传入。有了它们,线上容器出问题时,docker inspect一下就知道这个镜像对应哪次提交,不用再去翻 CI 记录对时间戳。我现在的习惯是把它做成组织级模板,所有项目统一带这几个标签,省去每次从零写。

最后分享一个我在实际项目中养成的小习惯:把 Dockerfile 当成代码一样做 review,重点关注四件事——RUN有没有合并、COPY顺序对不对、有没有切非 root、基础镜像的 tag 是否固定。这四条过了,剩下的细节可以慢慢优化。比起一次性写出一份完美的 Dockerfile,我更相信把这几条变成团队约定,长期看收益更大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询