本文已过一年。较旧的文章可能包含过时的内容。请检查页面中的信息自发布以来是否已变得不正确。
我激动人心的 Kubernetes 历史之旅
编者按:Sascha 是 SIG Release 的成员,并且正在研究许多其他不同的容器运行时相关主题。欢迎在 Twitter 上联系他 @saschagrunert。
一个关于使用 Kubeflow、TensorFlow、Prow 和一个完全自动化的 CI/CD 管道进行数据科学分析 90,000 个 GitHub 问题和拉取请求的故事。
简介
在数据科学领域工作时选择正确的步骤并非真正的灵丹妙药。大多数数据科学家可能都有他们自定义的工作流程,这些工作流程可能或多或少是自动化的,具体取决于他们的工作领域。当尝试大规模自动化工作流程时,使用 Kubernetes 可以是一个巨大的改进。在这篇博文中,我想带你踏上我在将整个工作流程集成到 Kubernetes 中的同时进行数据科学的旅程。
我过去几个月所做的研究目标是找到关于我们在 Kubernetes 存储库中的数千个 GitHub 问题和拉取请求 (PR) 的任何有用信息。我最终得到的是一个完全自动化、在 Kubernetes 中运行的由 Kubeflow 和 Prow 提供支持的持续集成 (CI) 和部署 (CD) 数据科学工作流程。你可能不了解它们,但我们会深入到我详细解释它们在做什么的程度。我的工作的源代码可以在 kubernetes-analysis GitHub 存储库中找到,其中包含所有与源代码相关的内容以及原始数据。但是如何检索我所说的这些数据呢?好吧,这就是故事的开始。
获取数据
我的实验的基础是纯 JSON 格式的原始 GitHub API 数据。可以通过 GitHub issues 端点检索必要的数据,该端点在 REST API 中返回所有拉取请求以及常规问题。在第一次迭代中,我导出了大约 91000 个问题和拉取请求,形成一个巨大的 650 MiB 数据块。这花了我大约 8 个小时 的数据检索时间,因为 GitHub API 当然是 速率受限 的。为了能够将此数据放入 GitHub 存储库中,我选择通过 xz(1)
对其进行压缩。结果是一个大约 25 MiB 大小的 tarball,非常适合放入存储库中。
我必须找到一种方法来定期更新数据集,因为 Kubernetes 问题和拉取请求会随着时间的推移被用户更新,并且会创建新的问题和拉取请求。为了实现持续更新而无需一遍又一遍地等待 8 个小时,我现在获取 上次更新 和当前时间之间的增量 GitHub API 数据。这样,持续集成作业可以定期更新数据,而我可以继续研究最新的可用数据集。
从工具的角度来看,我编写了一个 多合一的 Python 可执行文件,它允许我们通过专用的子命令单独触发数据科学实验中的不同步骤。例如,要运行整个数据集的导出,我们可以调用
> export GITHUB_TOKEN=<MY-SECRET-TOKEN>
> ./main export
INFO | Getting GITHUB_TOKEN from environment variable
INFO | Dumping all issues
INFO | Pulling 90929 items
INFO | 1: Unit test coverage in Kubelet is lousy. (~30%)
INFO | 2: Better error messages if go isn't installed, or if gcloud is old.
INFO | 3: Need real cluster integration tests
INFO | 4: kubelet should know which containers it is managing
… [just wait 8 hours] …
要更新存储库中存储的上次时间戳之间的数据,我们可以运行
> ./main export --update-api
INFO | Getting GITHUB_TOKEN from environment variable
INFO | Retrieving issues and PRs
INFO | Updating API
INFO | Got update timestamp: 2020-05-09T10:57:40.854151
INFO | 90786: Automated cherry pick of #90749: fix: azure disk dangling attach issue
INFO | 90674: Switch core master base images from debian to distroless
INFO | 90086: Handling error returned by request.Request.ParseForm()
INFO | 90544: configurable weight on the CPU and memory
INFO | 87746: Support compiling Kubelet w/o docker/docker
INFO | Using already extracted data from data/data.pickle
INFO | Loading pickle dataset
INFO | Parsed 34380 issues and 55832 pull requests (90212 items)
INFO | Updating data
INFO | Updating issue 90786 (updated at 2020-05-09T10:59:43Z)
INFO | Updating issue 90674 (updated at 2020-05-09T10:58:27Z)
INFO | Updating issue 90086 (updated at 2020-05-09T10:58:26Z)
INFO | Updating issue 90544 (updated at 2020-05-09T10:57:51Z)
INFO | Updating issue 87746 (updated at 2020-05-09T11:01:51Z)
INFO | Saving data
这让我们了解了该项目的实际进展速度:在周六中午(欧洲时间),5 个问题和拉取请求在 5 分钟内得到了更新!
有趣的是,Kubernetes 的创始人之一 Joe Beda 创建了第一个 GitHub 问题 提到单元测试覆盖率太低。该问题没有标题以外的进一步描述,也没有应用像我们从最近的问题和拉取请求中了解到的增强标签。但是现在我们必须更深入地探索导出的数据才能用它做一些有用的事情。
探索数据
在开始创建机器学习模型并对其进行训练之前,我们必须了解数据的结构以及我们总体上想要实现的目标。
为了更好地了解数据量,让我们看看 Kubernetes 存储库中随着时间的推移创建了多少问题和拉取请求
> ./main analyze --created
INFO | Using already extracted data from data/data.pickle
INFO | Loading pickle dataset
INFO | Parsed 34380 issues and 55832 pull requests (90212 items)
Python matplotlib 模块应该会弹出一个如下所示的图表
好的,这看起来不是那么壮观,但让我们对该项目在过去 6 年中的发展情况有所了解。为了更好地了解项目开发的进展速度,我们可以查看创建与关闭指标。这意味着在我们的时间线上,如果创建了一个问题或拉取请求,我们会在 y 轴上加一,如果关闭,则减一。现在图表如下所示
> ./main analyze --created-vs-closed
在 2018 年初,Kubernetes 项目通过出色的 fejta-bot 引入了一些更强化的生命周期管理。这会在较长时间后自动关闭过时的问题和拉取请求。这导致了大量问题的关闭,但这并不适用于相同数量的拉取请求。例如,如果我们仅查看拉取请求的创建与关闭指标。
> ./main analyze --created-vs-closed --pull-requests
总体影响并不那么明显。我们可以看到的是,PR 图表中峰值的数量不断增加,表明该项目正在随着时间的推移而加快速度。通常,烛台图表是显示此类与波动性相关的信息的更好选择。我还想强调的是,该项目的开发似乎在 2020 年初有所放缓。
在每次分析迭代中解析原始 JSON 不是 Python 中最快的方法。这意味着我决定将更重要的信息(例如内容、标题和创建时间)解析为专用的 问题 和 PR 类。此数据也将 pickle 序列化到存储库中,这允许独立于 JSON Blob 的整体更快的启动。
在我的分析中,拉取请求或多或少与问题相同,除了它包含发布说明。
Kubernetes 中的发布说明写在 PR 的描述中的一个单独的 release-note
块中,如下所示
```release-note
I changed something extremely important and you should note that.
```
这些发行说明由专用的发布工程工具(如 krel
)在发布创建过程中进行解析,并将成为各个CHANGELOG.md文件和发行说明网站的一部分。这看起来很神奇,但最终,整体发行说明的质量要高得多,因为它们易于编辑,并且 PR 审查者可以确保我们只记录真正面向用户的更改,而不会记录其他内容。
在进行数据科学时,输入数据的质量是关键方面。我决定专注于发行说明,因为与问题和 PR 中的纯文本描述相比,它们的整体质量似乎最高。除此之外,它们易于解析,而且我们不需要剥离各种问题和PR 模板中的文本噪声。
标签、标签、标签
Kubernetes 中的问题和拉取请求在其生命周期中会应用不同的标签。它们通常通过单个斜杠 (/
) 分组。例如,我们有 kind/bug
和 kind/api-change
或 sig/node
和 sig/network
。了解哪些标签组存在以及它们在存储库中的分布情况的一种简单方法是将它们绘制成条形图。
> ./main analyze --labels-by-group
看起来 sig/
、kind/
和 area/
标签非常常见。像 size/
这样的标签现在可以忽略,因为这些标签是根据拉取请求的代码更改量自动应用的。我们说过要专注于发行说明作为输入数据,这意味着我们必须检查 PR 的标签分布。这意味着拉取请求上的前 25 个标签是
> ./main analyze --labels-by-name --pull-requests
同样,我们可以忽略诸如 lgtm
(看起来不错)之类的标签,因为现在每个应该合并的 PR 都必须看起来不错。包含发行说明的拉取请求会自动应用 release-note
标签,这使得进一步筛选更加容易。但这并不意味着每个包含该标签的 PR 也包含发行说明块。该标签可能是手动应用的,并且从项目开始就不存在发行说明块的解析。这意味着一方面我们可能会丢失相当数量的输入数据。另一方面,我们可以专注于最高可能的数据质量,因为正确应用标签需要项目及其贡献者具备一定的成熟度。
从标签组的角度来看,我选择专注于 kind/
标签。这些标签是 PR 的作者必须手动应用的,它们在大量拉取请求中可用,并且也与面向用户的更改相关。除此之外,每个拉取请求都必须进行 kind/
选择,因为它是 PR 模板的一部分。
好的,当我们只关注具有发行说明的拉取请求时,这些标签的分布情况如何?
> ./main analyze --release-notes-stats
有趣的是,我们大约有 7,000 个包含发行说明的整体拉取请求,但只有大约 5,000 个应用了 kind/
标签。标签的分布不均,其中三分之一被标记为 kind/bug
。这让我做出了数据科学之旅中的下一个决定:我将构建一个二元分类器,为了简单起见,它只能区分错误(通过 kind/bug
)和非错误(未应用标签)。
现在的主要目标是能够根据我们从社区获得的现有历史数据,对新传入的发行说明进行分类,判断它们是否与错误相关。
在执行此操作之前,我建议您也尝试使用 ./main analyze -h
子命令来探索最新的数据集。您还可以查看我在分析存储库中提供的所有持续更新的资产。例如,这些是 Kubernetes 存储库中前 25 名 PR 创建者
构建机器学习模型
现在我们对数据集有了一个了解,我们可以开始构建第一个机器学习模型。在实际构建模型之前,我们必须预处理从 PR 中提取的所有发行说明。否则,模型将无法理解我们的输入。
进行一些初步的自然语言处理 (NLP)
首先,我们必须定义一个要训练的词汇表。我决定选择 Python scikit-learn 机器学习库中的TfidfVectorizer。此向量化器能够获取我们的输入文本并从中创建一个巨大的词汇表。这就是我们所谓的词袋,其 n-gram 范围选择为 (1, 2)
(一元组和二元组)。实际上,这意味着我们始终使用第一个单词和下一个单词作为单个词汇条目(二元组)。我们还使用单个单词作为词汇条目(一元组)。TfidfVectorizer 能够跳过多次出现的单词 (max_df
),并需要一个最小量 (min_df
) 才能将单词添加到词汇表中。我决定首先不更改这些值,因为我直觉地认为发行说明对于项目来说是独一无二的。
诸如 min_df
、max_df
和 n-gram 范围之类的参数可以被视为我们的一些超参数。这些参数必须在构建机器学习模型后在一个专门的步骤中进行优化。此步骤称为超参数调整,基本上意味着我们使用不同的参数多次训练并比较模型的准确性。之后,我们选择准确性最高的参数。
在训练期间,向量化器将生成一个 data/features.json
,其中包含整个词汇表。这使我们很好地了解了词汇表可能的样子
[
…
"hostname",
"hostname address",
"hostname and",
"hostname as",
"hostname being",
"hostname bug",
…
]
这会在整个词袋中产生大约 50,000 个条目,这非常多。先前在不同数据集之间的分析表明,根本没有必要考虑这么多特征。一些通用数据集指出,20,000 个的整体词汇量就足够了,更高的数量不再影响准确性。为此,我们可以使用SelectKBest特征选择器来缩小词汇表,仅选择最重要的特征。无论如何,我仍然决定坚持使用前 50,000 个,以免对模型准确性产生负面影响。我们拥有的数据量相对较少(大约 7,000 个样本),并且每个样本的单词数量较少(约 15 个),这已经让我怀疑我们是否拥有足够的数据。
向量化器不仅能够创建我们的词袋,还能够以词频-逆文档频率 (tf-idf)格式编码特征。这就是向量化器的名字的由来,而该编码的输出是机器学习模型可以直接使用的东西。向量化过程的所有详细信息都可以在源代码中找到。
创建多层感知器 (MLP) 模型
我决定选择一个简单的基于 MLP 的模型,该模型是在流行的 TensorFlow 框架的帮助下构建的。因为我们没有那么多输入数据,我们只使用两个隐藏层,所以该模型基本上是这样的
在创建模型时,还有多个其他超参数需要考虑。我在这里不会详细讨论它们,但它们对于根据我们要在模型中拥有的类别的数量(在本例中只有两个)进行优化也很重要。
训练模型
在开始实际训练之前,我们必须将输入数据分成训练数据集和验证数据集。我选择使用大约 80% 的数据进行训练,20% 的数据用于验证目的。我们还必须打乱输入数据,以确保模型不受排序问题的影响。训练过程的技术细节可以在 GitHub 源代码中找到。所以现在我们准备好最终开始训练了
> ./main train
INFO | Using already extracted data from data/data.pickle
INFO | Loading pickle dataset
INFO | Parsed 34380 issues and 55832 pull requests (90212 items)
INFO | Training for label 'kind/bug'
INFO | 6980 items selected
INFO | Using 5584 training and 1395 testing texts
INFO | Number of classes: 2
INFO | Vocabulary len: 51772
INFO | Wrote features to file data/features.json
INFO | Using units: 1
INFO | Using activation function: sigmoid
INFO | Created model with 2 layers and 64 units
INFO | Compiling model
INFO | Starting training
Train on 5584 samples, validate on 1395 samples
Epoch 1/1000
5584/5584 - 3s - loss: 0.6895 - acc: 0.6789 - val_loss: 0.6856 - val_acc: 0.6860
Epoch 2/1000
5584/5584 - 2s - loss: 0.6822 - acc: 0.6827 - val_loss: 0.6782 - val_acc: 0.6860
Epoch 3/1000
…
Epoch 68/1000
5584/5584 - 2s - loss: 0.2587 - acc: 0.9257 - val_loss: 0.4847 - val_acc: 0.7728
INFO | Confusion matrix:
[[920 32]
[291 152]]
INFO | Confusion matrix normalized:
[[0.966 0.034]
[0.657 0.343]]
INFO | Saving model to file data/model.h5
INFO | Validation accuracy: 0.7727598547935486, loss: 0.48470408514836355
混淆矩阵的输出显示,我们的训练准确性非常好,但验证准确性可能更高。我们现在可以开始超参数调整,看看是否可以进一步优化模型的输出。我将把这个实验留给您,并提示您使用 ./main train --tune
标志。
我们将模型 (data/model.h5
)、向量化器 (data/vectorizer.pickle
) 和特征选择器 (data/selector.pickle
) 保存到磁盘,以便能够在以后用于预测目的,而无需额外的训练步骤。
首次预测
我们现在可以通过从磁盘加载模型并预测一些输入文本来测试该模型
> ./main predict --test
INFO | Testing positive text:
Fix concurrent map access panic
Don't watch .mount cgroups to reduce number of inotify watches
Fix NVML initialization race condition
Fix brtfs disk metrics when using a subdirectory of a subvolume
INFO | Got prediction result: 0.9940581321716309
INFO | Matched expected positive prediction result
INFO | Testing negative text:
action required
1. Currently, if users were to explicitly specify CacheSize of 0 for
KMS provider, they would end-up with a provider that caches up to
1000 keys. This PR changes this behavior.
Post this PR, when users supply 0 for CacheSize this will result in
a validation error.
2. CacheSize type was changed from int32 to *int32. This allows
defaulting logic to differentiate between cases where users
explicitly supplied 0 vs. not supplied any value.
3. KMS Provider's endpoint (path to Unix socket) is now validated when
the EncryptionConfiguration files is loaded. This used to be handled
by the GRPCService.
INFO | Got prediction result: 0.1251964420080185
INFO | Matched expected negative prediction result
这两个测试都是真实世界的例子,它们已经存在。我们也可以尝试一些完全不同的东西,比如我几分钟前找到的这条随机推文
./main predict "My dudes, if you can understand SYN-ACK, you can understand consent"
INFO | Got prediction result: 0.1251964420080185
ERROR | Result is lower than selected threshold 0.6
看起来它没有被分类为发行说明的错误,这似乎是有效的。选择一个好的阈值也不是那么容易,但坚持使用 > 50% 应该是最低要求。
自动化一切
下一步是找到某种自动化方法,以使用新数据持续更新模型。如果我更改了存储库中的任何源代码,那么我希望获得关于模型测试结果的反馈,而无需在我自己的机器上运行训练。我想利用 Kubernetes 集群中的 GPU 来更快地训练,并在合并 PR 后自动更新数据集。
在 Kubeflow 管道的帮助下,我们可以满足大部分这些要求。我构建的管道如下所示
首先,我们检出 PR 的源代码,该代码将作为输出工件传递给所有其他步骤。然后,我们逐步更新 API 和内部数据,然后再在始终最新的数据集上运行训练。预测测试在训练后验证我们的更改是否对模型产生了不良影响。
我们还在管道中构建了一个容器镜像。此容器镜像将先前构建的模型、向量化器和选择器复制到容器中,并运行 ./main serve
。执行此操作时,我们启动一个kfserving Web 服务器,该服务器可用于预测目的。您想自己尝试一下吗?只需执行如下所示的 JSON POST 请求,并针对端点运行预测
> curl https://kfserving.k8s.saschagrunert.de/v1/models/kubernetes-analysis:predict \
-d '{"text": "my test text"}'
{"result": 0.1251964420080185}
自定义 kfserving 实现非常简单,而部署在后台利用 Knative Serving 和 Istio 入口网关来正确地将流量路由到集群中并提供正确的一组服务。
仅当管道在 master
分支上运行时,才会运行 commit-changes
和 rollout
步骤。这些步骤确保我们始终在 master 分支以及 kfserving 部署中拥有最新的数据集。rollout 步骤会创建一个新的金丝雀部署,该部署首先仅接受 50% 的传入流量。在成功部署金丝雀之后,它将提升为服务的新主实例。这是确保部署按预期工作的好方法,并允许在推出金丝雀后进行额外的测试。
但是如何在创建拉取请求时触发 Kubeflow 管道?Kubeflow 目前没有此功能。这就是我决定使用 Prow(Kubernetes 测试基础设施项目,用于 CI/CD 目的)的原因。
首先,一个24小时周期性任务确保了我们在仓库中至少有每日更新的数据。然后,如果我们创建一个拉取请求,Prow 将运行整个 Kubeflow 管道,而不会提交或推出任何更改。如果我们通过 Prow 合并拉取请求,另一个任务将在主分支上运行,并更新数据和部署。这非常棒,不是吗?
自动标记新的 PR
预测 API 对于测试来说很好,但现在我们需要一个真实的用例。Prow 支持外部插件,可以使用它们对任何 GitHub 事件采取操作。我写了一个插件,它使用 kfserving API 基于新的拉取请求进行预测。这意味着,如果现在我们在 kubernetes-analysis 仓库中创建一个新的拉取请求,我们将看到以下内容
好的,太棒了,现在让我们根据现有数据集中的一个真实错误来更改发布说明
机器人编辑了自己的评论,预测其为 kind/bug
的概率约为 90%,并自动添加了正确的标签!现在,如果我们将其改回一些不同的——显然是错误的——发布说明
机器人为我们完成了工作,删除了标签,并告知我们它做了什么!最后,如果我们把发布说明改为 None
机器人删除了评论,这很好,减少了 PR 上的文本噪音。我演示的所有内容都在一个 Kubernetes 集群中运行,这完全没有必要将 kfserving API 公开。这引入了间接的 API 速率限制,因为只能通过 Prow 机器人用户使用。
如果您想自己尝试一下,请随时在 kubernetes-analysis
中打开一个新的测试 issue。之所以可行,是因为我还为 issue 启用了插件,而不仅仅是拉取请求。
所以,我们现在有了一个运行中的 CI 机器人,它可以根据机器学习模型对新的发布说明进行分类。如果该机器人在官方 Kubernetes 仓库中运行,那么我们可以手动纠正错误的标签预测。这样,下一次训练迭代将采纳纠正,从而随着时间的推移不断改进模型。所有这一切都是完全自动化的!
总结
感谢您阅读到这里!这是我通过 Kubernetes GitHub 仓库进行的简短数据科学之旅。还有很多其他事情可以优化,例如引入更多的类别(不仅仅是 kind/bug
或没有)或使用 Kubeflow 的 Katib 进行自动超参数调整。如果您有任何问题或建议,请随时与我联系。再见!