Docker 反模式

容器的使用正在呈爆炸式增长。即使您还不确定 Kubernetes 是否是未来的发展方向,仅使用 Docker 本身就很容易带来价值。容器现在可以简化部署和 CI/CD 流水线。

Docker官方最佳实践页面技术性很强,更侧重于 Dockerfile 的结构,而不是关于如何使用容器的通用信息。每个 Docker 新手都会在某个时候了解 Docker 层的用法、它们的缓存方式以及如何创建小型 Docker 镜像。多阶段构建也并非难事。Dockerfile 的语法相当容易理解。

然而,容器使用的主要问题在于企业缺乏全局观,尤其是容器/镜像的不可更改性。一些公司甚至试图将其现有的基于虚拟机的流程转换为容器,但结果却令人生疑。关于容器底层细节(如何创建和运行容器)的信息非常丰富,但关于高层最佳实践的信息却非常少。

为了弥补文档方面的缺口,我为您列出了一系列 Docker 最佳实践。由于不可能涵盖所有公司的内部流程,因此我将解释一些不良做法(即您不应该做的事情)。希望这能让您就如何使用容器有所启发。

以下是我们将要检查的不良做法的完整列表:

尝试在容器上使用 VM 实践。
创建不透明的 Docker 文件。
创建具有外部副作用的 Dockerfile。
将用于部署的图像与用于开发的图像混淆。
每个环境构建不同的图像。
将代码从 git 拉入生产服务器并动态构建图像。
在团队之间推广 git 哈希。
将秘密硬编码到容器镜像中。
使用 Docker 作为穷人的 CI/CD。
假设容器是一种愚蠢的包装方法。

反模式 1 – 将 Docker 容器视为虚拟机

在介绍一些更实际的例子之前,我们先来了解一下基本理论。容器不是虚拟机。乍一看,它们的行为可能类似于虚拟机,但事实并非如此。Stackoverflow 和相关论坛上充斥着类似这样的问题:

如何更新容器内运行的应用程序?
如何在 Docker 容器中使用 ssh?
如何从容器中获取日志/文件?
如何在容器内应用安全修复?
如何在容器中运行多个程序?

从技术层面上来说,所有这些问题都是正确的,回答这些问题的人也给出了技术层面上正确的答案。然而,所有这些问题都是XY 问题的典型例子。这些问题背后真正的问题是:

“我怎样才能忘记所有的虚拟机实践和流程,并改变我的工作流程以使用不可变的、短暂的、无状态的容器而不是可变的、长期运行的、有状态的虚拟机?”

许多公司正试图在容器世界中复用虚拟机的实践/工具/知识。有些公司甚至措手不及,因为当容器出现时,他们甚至还没有完成从裸机到虚拟机的迁移。

忘记一些东西非常困难。大多数刚开始使用容器的人最初都把它们视为现有实践之上的一个额外的抽象层:

容器不是虚拟机

实际上,容器需要对现有流程进行完全不同的视角和变革。采用容器时,您需要重新思考所有CI/CD 流程。

容器需要新的思维方式

除了了解容器的性质、其构造块和历史(一直追溯到古老的chroot)之外,没有其他简单的方法可以解决这种反模式。

如果您经常发现自己想要打开 ssh 会话来运行容器以“升级”它们或手动从中获取日志/文件,那么您肯定以错误的方式使用 Docker,并且您需要阅读一些有关容器如何工作的额外资料。

反模式 2 – 创建不透明的 Docker 镜像

Dockerfile 应该透明且自包含。它应该清晰地描述应用程序的所有组件。任何人都应该能够获取相同的 Dockerfile 并重新创建相同的镜像。Dockerfile 可以下载额外的库(以版本控制且控制良好的方式),但应避免创建执行“魔法”步骤的 Dockerfile。

这是一个特别糟糕的例子:

来自alpine : 3.4

<字体>< /字体>

运行apk添加 –no – cache

​ ca-证书

​ pciutils <字体>< /字体>

​ ruby <字体>< /字体>

​ ruby-irb <字体>< /字体>

​ ruby-rdoc <字体>< /字体>

​ && <字体>< /字体>

​ 回显 http://dl-4.alpinelinux.org/alpine/edge/community/ >> /etc/apk/repositories &&

​ apk 添加 –no-cache shadow && <字体>< /字体>

​ gem 安装 puppet:“5.5.1” facter:“2.5.1” && < font >< /font >

/ usr / bin /puppet模块 安装 puppetlabs-apk

<字体>< /字体>

# 安装 Java 应用程序

运行 / usr /bin/puppet agent –onetime –no – daemonize

<字体>< /字体>

入口点[ “java” ,“-jar” ,“/app/spring-boot-application.jar” ]< font >< /font >

别误会我的意思。我喜欢 Puppet,因为它是个很棒的工具(或者说,Ansible 和 Chef 也不错)。在虚拟机上,误用 Puppet 进行应用程序部署可能很容易,但在容器上,误用 Puppet 则会带来灾难性的后果。

首先,它使这个 Dockerfile 依赖于位置。你必须在一台可以访问生产 Puppet 服务器的计算机上构建它。你的工作站可以访问生产 Puppet 服务器吗?如果可以,你的工作站真的应该可以访问生产 Puppet 服务器吗?

但最大的问题是,这个 Docker 镜像无法轻易重建。它的内容取决于 Puppet 服务器初始构建时的内容。如果您今天构建相同的 Dockerfile,可能会得到一个完全不同的镜像。而且,如果您无法访问 Puppet 服务器,或者 Puppet 服务器宕机了,您甚至根本无法构建镜像。如果您无法访问 Puppet 脚本,您甚至不知道应用程序的版本号。

创建这个 Dockerfile 的团队太懒了。已经有了一个用于在虚拟机中安装应用程序的 Puppet 脚本。Dockerfile 只是被改造了一下,用来做同样的事情(参见之前的反模式)。

解决方法是使用最小化的 Dockerfile,明确描述它们的作用。以下是同一个应用程序,但使用了“正确的”Dockerfile。

来自openjdk: 8 -jdk – alpine

<字体>< /字体>

ENV MY_APP_VERSION = ” 3.2 ”

<字体>< /字体>

运行apk添加 –no – cache

​ ca-证书

<字体>< /字体>

WORKDIR /app <字体>< /字体>

添加 http://artifactory.mycompany.com/releases/${MY_APP_VERSION}/spring-boot-application.jar 。

<字体>< /字体>

入口点[ “java” ,“-jar” ,“/app/spring-boot-application.jar” ]< font >< /font >

请注意:

不依赖 Puppet 基础设施。Dockerfile 可以在任何可以访问二进制仓库的开发者机器上构建。
软件的版本有明确定义。
只需编辑 Dockerfile(而不是 puppet 脚本)即可轻松更改应用程序的版本。

这只是一个非常简单(且人为设计)的示例。我见过很多 Dockerfile 依赖于“魔法”配方,这些配方对构建的时间和地点有特殊要求。请不要以这种方式编写 Dockerfile,因为开发人员(以及其他无法访问所有系统的人员)在本地创建 Docker 镜像会非常困难。

更好的选择是,Dockerfile 可以自行编译 Java 源码(使用多阶段构建)。这样可以让你更好地了解 Docker 镜像中发生的情况。

反模式 3 – 创建具有外部副作用的 Dockerfile

假设你是一位运营/SRE,在一家使用多种编程语言的大型公司工作。要成为所有编程语言的专家并构建系统将非常困难。

这首先是采用容器的主要优势之一。您应该能够从任何开发团队下载任何 Dockerfile 并进行构建,而无需真正关心副作用(因为不应该有任何副作用)。

构建 Docker 镜像应该是一个幂等操作。无论您只构建同一个 Dockerfile 一次还是数千次,或者先在 CI 服务器上构建,然后再在工作站上构建,这都无关紧要。

然而,在构建阶段,有多个 Dockerfile……

执行 git 提交或其他 git 操作,
清理或篡改数据库数据,
或者使用POST/PUT操作调用其他外部服务。

就主机文件系统而言,容器提供了隔离,但是没有什么可以保护您免受包含 RUN 指令的 Dockerfile 的侵害,该指令使用 curl 将 HTTP 有效负载发布到您的内联网。

这是一个简单的例子,其中 Dockerfile 在一次运行中打包(安全操作)并发布(不安全操作)npm 应用程序。

来自节点:9

WORKDIR /app <字体>< /字体>

<字体>< /字体>

复制package.json ./package.json

复制package – lock.json ./package-lock.json

运行npm install

复制…

<字体>< /字体>

运行npm测试

<字体>< /字体>

ARG npm_token <字体>< /字体>

<字体>< /字体>

运行 echo “//registry.npmjs.org/:_authToken=${npm_token}” > .npmrc > < /font >

运行npm publish –access public

<字体>< /字体>

暴露8080 <字体>< /字体>

CMD [ “npm” ,“启动” ]< font >< /font >

这个 Docker 文件混淆了两个不相关的概念:发布应用程序版本和为其创建 Docker 镜像。有时这两个操作确实会同时发生,但这并不是用副作用污染 Dockerfile 的借口。

Docker并非通用的 CI 系统,也从未打算成为那样的系统。不要将 Dockerfile 滥用为拥有无限权力的 bash 脚本。容器运行时出现副作用是可以接受的,但在容器构建时出现副作用则不行。

解决方案是简化 Dockerfile 并确保它们仅包含幂等操作,例如:

克隆源代码
下载依赖项
编译/打包代码
处理/缩小/转换本地资源
仅在容器文件系统上运行脚本和编辑文件

另外,请记住 Docker 缓存文件系统层的方式。Docker 假设,如果某个层及其之前的层没有“更改”,则可以从缓存中重用它们。如果您的 Dockerfile 指令有副作用,那么您实际上会破坏 Docker 缓存机制。

来自节点:10.15 -jessie

<字体>< /字体>

运行 apt-get update && apt-get install -y mysql-client && rm -rf /var/lib/apt < font >< /font >

<字体>< /字体>

运行 mysql -u root –password= “” < test/prepare-db- for -tests.sql < font > < /font >

<字体>< /字体>

WORKDIR /app <字体>< /字体>

<字体>< /字体>

复制package.json ./package.json

复制package – lock.json ./package-lock.json

运行npm install

复制…

<字体>< /字体>

运行npm integration – test

<字体>< /字体>

暴露8080 <字体>< /字体>

CMD [ “npm” ,“启动” ]< font >< /font >

假设你尝试构建这个 Dockerfile,但单元测试失败了。你修改了源代码,然后尝试再次构建。Docker 会假设清除数据库的层已经“运行”,并会重用缓存。因此,你的单元测试现在将在未清理且包含上次运行数据的数据库中运行。

在这个设计示例中,Dockerfile 非常小,很容易找到有副作用的语句(mysql 命令),并将其移动到正确的位置以修复层缓存。但在包含许多命令的实际 Dockerfile 中,如果不知道哪些有副作用,哪些没有副作用,那么试图找出 RUN 语句的正确顺序将非常困难。

如果您的 Dockerfile 执行的所有操作都是只读的并且具有本地范围,那么它们将会更加简单。

反模式 4 – 混淆用于开发的镜像和用于部署的镜像

在任何采用容器的公司中,通常都有两类独立的 Docker 镜像。首先,是用作实际部署工件发送到生产服务器的镜像。

部署映像应包含:

最小化/编译形式的应用程序代码及其运行时依赖项。
没别的了。真的没别的了。

第二类是用于 CI/CD 系统或开发人员的图像,可能包含:

原始形式的源代码(即未压缩的)
编译器/压缩器/转译器
测试框架/报告工具
安全扫描、质量扫描、静态分析器
云集成工具
CI/CD 管道所需的其他实用程序

显然,这些容器镜像类别应该分开处理,因为它们的用途和目标不同。部署到服务器的镜像应该是精简、安全且经过严格测试的。用于CI/CD流程的镜像实际上从未部署到任何地方,因此对它们的要求(包括大小和安全性)要宽松得多。

然而,出于某种原因,人们并不总是理解这种区别。我见过一些公司试图在开发和部署中使用同一个 Docker 镜像。结果几乎总是如此:不相关的实用程序和框架最终出现在生产 Docker 镜像中。

生产 Docker 镜像应该包含 git、测试框架或编译器/最小化器的理由完全为零。

容器作为通用部署工具的承诺始终在于在不同环境之间使用相同的部署工具,并确保测试的内容与部署的内容一致(稍后会详细介绍)。但试图将本地开发与生产部署整合在一起是一场注定失败的战斗。

总而言之,尝试理解 Docker 镜像的角色。每个镜像应该只有一个角色。如果你将测试框架/库发布到生产环境,那么你的做法是错误的。你还应该花一些时间学习和使用多阶段构建。

反模式 5 – 为每个环境(qa、阶段、生产)使用不同的镜像

使用容器最重要的优势之一是其不可变属性。这意味着 Docker 镜像只需构建一次,然后推广到各种环境,直到达到生产环境。

推广相同的 Docker 镜像

由于完全相同的图像被提升为单个实体,因此您可以保证在一个环境中测试的内容与另一个环境中测试的内容相同。

我看到许多公司使用略有不同的代码或配置版本为他们的环境构建不同的工件。

每个环境使用不同的图像

这很成问题,因为无法保证镜像“足够相似”,从而验证它们的行为方式是否相同。这也为滥用提供了许多可能性,开发人员/运维人员会在非生产镜像中偷偷添加额外的调试工具,从而导致不同环境的镜像之间的差异进一步扩大。

无需尝试确保不同的图像尽可能相同,而是在所有软件生命周期阶段使用单个图像要容易得多。

请注意,如果不同的环境使用不同的设置(例如 secrets 和配置变量),这是完全正常的,我们将在本文后面看到。但是,其他一切都应该完全相同。

反模式 6 – 在生产服务器上创建 Docker 镜像

Docker 镜像仓库是现有应用程序的目录,可以随时重新部署到任何其他环境。它也是应用程序资产的中心位置,包含额外的元数据以及同一应用程序的先前历史版本。选择 Docker 镜像的特定标签并将其部署到任何环境应该非常容易。

使用 Docker 镜像仓库最灵活的方式之一是在它们之间推广镜像。一个组织至少有两个镜像仓库(开发镜像仓库和生产镜像仓库)。Docker 镜像应该构建一次(参见上一个反模式),并将其放置在开发镜像仓库中。然后,一旦集成测试、安全扫描和其他质量门验证了其功能正确,就可以将该镜像推广到生产 Docker 镜像仓库,以便将其发送到生产服务器或 Kubernetes 集群。

也可以按区域/位置或部门为 Docker 注册表设置不同的组织。这里的重点是,Docker 部署的规范方式也包含 Docker 注册表。Docker 注册表既可用作应用程序资产存储库,也可用作应用程序部署到生产环境之前的中间存储。

一种非常值得怀疑的做法是从生命周期中完全删除 Docker 注册表并将源代码直接推送到生产服务器。

在生产服务器中构建镜像

生产服务器使用“git pull”获取源代码,然后使用 Docker build 动态创建镜像并在本地运行(通常使用 Docker-compose 或其他自定义编排工具)。这种“部署方法”本质上同时使用了多种反模式!

这种部署实践存在诸多问题,首先就是安全性。生产服务器不应该拥有对 Git 仓库的入站访问权限。如果一家公司非常重视安全性,这种模式甚至连安全委员会都无法接受。生产服务器也没有理由安装 Git。Git(或任何其他版本控制系统)是一个旨在帮助开发人员协作的工具,而不是一个工件交付解决方案。

但最关键的问题是,这种“部署方法”完全绕过了 Docker 镜像仓库的作用范围。由于不再有中心化的位置来保存 Docker 镜像,你无法知道服务器上部署了什么 Docker 镜像。

这种部署方法在初创公司可能行得通,但在更大规模的部署中很快就会变得效率低下。你需要学习如何使用 Docker 镜像仓库及其带来的优势(也与容器的安全扫描相关)。

使用 Docker 注册表

Docker 注册表具有明确定义的 API,并且有几种开源和专有产品可用于在您的组织内设置一个。

还要注意,使用 Docker 注册表,您的源代码可以安全地驻留在防火墙后面,并且永远不会离开现场。

反模式 7 – 使用 git hashes 而不是 Docker 镜像

前两种反模式的必然结果是,一旦采用容器技术,Docker 镜像仓库就应该成为一切的单一真实来源。人们应该使用 Docker 标签和镜像推广进行交流。开发人员和运维人员应该使用容器作为他们的通用语言。团队之间的交接实体应该是容器,而不是 git hash。

谈论 git hashes

这与过去使用 Git 哈希作为“推广”工具的做法形成了鲜明对比。源代码当然重要,但为了推广而一遍又一遍地重建相同的哈希值,是对资源的浪费(另见反模式 5)。一些公司认为容器应该只由运维人员处理,而开发人员仍然只能使用源代码。这完全不符合事实。容器是开发人员和运维人员协同工作的绝佳机会。

谈论容器

理想情况下,运维人员甚至不应该关心应用程序的 git 仓库发生了什么。他们只需要知道手头的 Docker 镜像是否准备好推送到生产环境。他们不应该被迫重建 git 哈希,以获得与开发人员在预生产环境中使用的相同的 Docker 镜像。

你可以通过询问组织中的运维人员来了解你是否是这种反模式的受害者。如果他们被迫熟悉应用程序内部结构(例如通常与应用程序实际运行时无关的构建系统或测试框架),他们的认知负担就会很重,而这些负担在日常运维中是不必要的。

反模式 8 – 将机密信息和配置硬编码到容器镜像中

这种反模式与反模式 5(每个环境使用不同的镜像)密切相关。大多数情况下,当我问公司为什么需要为 QA/Staging/生产环境使用不同的镜像时,他们通常会回答说,这些镜像包含不同的配置和机密信息。

这不仅违背了 Docker 的主要承诺(部署您测试的内容),而且还使所有 CI/CD 管道变得非常复杂,因为它们必须在构建期间管理机密/配置。

这里的反模式当然是配置的硬编码。应用程序不应该有嵌入式配置。对于任何熟悉12 要素应用程序的人来说,这应该不是什么新鲜事。

构建时硬编码配置

您的应用程序应该在运行时而不是构建时获取配置。Docker 镜像应该与配置无关。只有在运行时,配置才应该“附加”到容器。有很多解决方案可以解决这个问题,大多数集群/部署系统都可以使用运行时配置(configmaps、zookeeper、consul等)和 secrets(vault、keywhiz、confidant、cerberus)的解决方案。

运行时加载配置

如果您的 Docker 镜像具有硬编码的 IP 和/或凭证,那么您肯定做错了。

反模式 9 – 创建过多的 Docker 文件

我曾看到一些文章,建议将 Dockerfile 用作低级 CI 解决方案。以下是一个实际的单个 Dockerfile 示例。

# 运行 Sonar 分析

来自 newtmitch/sonar-scanner 作为 sonar < font >< /font >

复制src src

运行声纳扫描仪

# 构建应用程序

来自节点:11 AS构建

WORKDIR /usr/src/app <字体>< /字体>

复制…

运行yarn install

yarn run lint

纱线运行构建<字体>< /字体>

yarn run生成文档

标签阶段=构建<字体>< /字体>

# 运行单元测试

来自构建 AS 单元测试< font >< /font >

运行yarn run单元测试

标签阶段=单元测试<字体>< /字体>

# 将文档推送到 S3

来自 containerlabs/aws-sdk AS push-docs < font >< /font >

ARG push-docs= false <字体>< /字体>

复制 –from=build docs docs < font >< /font >

运行[[ “$push-docs” == true ]] && aws s3 cp -r docs s3: //my-docs-bucket/

# 构建最终应用程序

来自节点:11 -slim

暴露8080 <字体>< /字体>

WORKDIR /usr/src/app <字体>< /字体>

复制–from=build / usr /src/app / node_modules node_modules

复制 –from=build /usr/src/app / dist dist

USER 节点< font >< /font >

CMD [ “node” ,“./dist/server/index.js” ]< font >< /font >

虽然乍一看这个 Docker 文件似乎很好地利用了多阶段构建,但它本质上是以前的反模式的组合。

它假设存在一个 SonarQube 服务器(反模式 2)。
它具有潜在的副作用,因为它可以推送到 S3(反模式 3)。
它既充当开发映像,又充当部署映像(反模式 4)。

Docker 本身并不是一个 CI 系统。容器技术可以用作 CI/CD 流水线的一部分,但这项技术完全不同。不要将需要在 Docker 容器中运行的命令与需要在 CI 构建作业中运行的命令混淆。

这个 Dockerfile 的作者建议你使用与标签交互的构建参数,并开启/关闭特定的构建阶段(例如,你可以禁用声纳)。但这种方法只是为了增加复杂性而增加复杂性。

解决这个问题的方法是将其拆分成另外 5 个 Dockerfile。其中一个用于应用程序部署,其余的则作为 CI/CD 流水线中的不同流水线步骤。单个 Dockerfile 应该只有一个目的/目标。

反模式 10 – 创建功能太少的 Docker 文件

由于容器本身也包含依赖项,因此非常适合隔离每个应用程序的库和框架版本。开发人员已经熟悉在工作站上安装同一工具的多个版本所带来的问题。Docker 承诺通过允许您在 Dockerfile 中精确描述应用程序所需的内容(仅此而已)来解决此问题。

但 Docker 的这个承诺只有在你真正使用它时才有效。作为一名运维人员,我不应该真正关心你在 Docker 镜像中使用的编程工具。我应该能够创建一个 Java 应用程序的 Docker 镜像,然后是一个 Python 镜像,再是一个 Node.js 镜像,而无需在我的笔记本电脑上为每种语言都准备一个开发环境。

然而,许多公司仍然将 Docker 视为一种愚蠢的打包格式,仅仅用它来打包已经在容器之外创建完成的工件/应用程序。这种反模式在 Java 重度使用型组织中非常常见,甚至官方文档似乎也在推广它。

这是官方 Spring Boot Docker 指南中建议的 Dockerfile 。

来自openjdk: 8 -jdk – alpine

音量 /tmp <字体>< /字体>

参数 JAR_FILE <字体>< /字体>

复制$ { JAR_FILE } app.jar

入口点[ “java” ,“-Djava.security.egd=file:/dev/./urandom” ,“-jar” ,“/app.jar” ]< font >< /font >

这个 Dockerfile 只是打包了一个现有的 jar 文件。这个 jar 文件是如何创建的呢?没人知道。Dockerfile 中没有描述。如果我是一名运维人员,为了构建这个 Dockerfile,我不得不在本地安装所有 Java 开发库。如果你所在的组织使用多种编程语言,那么这个过程很快就会失控,不仅对于运维人员来说如此,对于构建节点来说也是如此。

我这里以 Java 为例,但这种反模式在其他情况下也存在。Dockerfile 除非先在本地执行“npm install”才能运行,这种情况很常见。

针对此反模式的解决方案与反模式 2(Dockerfile 不自包含)相同。确保您的 Dockerfile 描述某个操作的完整流程。如果您遵循这种方法,您的运维人员/SRE 会更加满意。对于之前的 Java 示例,Dockerfile 应进行如下修改:

来自openjdk: 8 -jdk – alpine

复制pom.xml / tmp /

复制src / tmp /src /

WORKDIR /tmp/ <字体>< /字体>

运行./gradlew build

复制/tmp/build/app.war /app.jar

入口点[ “java” ,“-Djava.security.egd=file:/dev/./urandom” ,“-jar” ,“/app.jar” ]< font >< /font >

这个 Dockerfile 详细描述了应用程序的创建方式,任何人都可以在任何工作站上运行,无需本地安装 Java。你还可以使用多阶段构建功能进一步改进这个 Dockerfile(读者练习)。

概括

许多公司在采用容器技术时遇到困难,因为他们试图将现有的虚拟机实践强行塞进容器中。最好花些时间重新思考容器的所有优势,并了解如何利用这些新知识从头开始创建自己的流程。

在本指南中,我介绍了容器使用中的几种不良做法以及每种做法的解决方案。

尝试在容器上使用虚拟机实践。解决方案:了解什么是容器。
创建不透明的 Docker 文件。解决方案:从头编写 Dockerfile,而不是采用现有脚本。
创建具有外部副作用的 Dockerfile。解决方案:将副作用转移到 CI/CD 解决方案中,并确保 Dockerfile 不产生副作用。
混淆了用于部署的镜像和用于开发的镜像。解决方案:不要将开发工具和测试框架发送到生产服务器。
每个环境构建不同的镜像。解决方案:只构建一次镜像,并在不同环境中推广。
从 git 导入生产服务器并动态构建镜像。解决方案:使用 Docker 镜像仓库
在团队之间推广 Git 哈希值。解决方案:在团队之间推广容器镜像
将机密信息硬编码到容器镜像中。解决方案:只构建一次镜像,并使用运行时配置注入
使用 Docker 作为 CI/CD。解决方案:使用 Docker 作为部署构件,并为 CI/CD 选择合适的 CI/CD 解决方案。
假设容器是一种愚蠢的打包方法。解决方案:创建 Dockerfile,从头开始自行编译/打包源代码。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容