这篇文章已超过一年。较旧的文章可能包含过时内容。请检查页面信息自发布以来是否已失效。

我探索 Kubernetes 历史的激动人心之旅

编辑注:Sascha 是 SIG Release 的一员,并且正在研究许多其他不同的容器运行时相关话题。欢迎在 Twitter @saschagrunert 上联系他。


一个关于数据科学的故事:如何使用 Kubeflow、TensorFlow、Prow 和全自动 CI/CD 流水线处理 9 万条 GitHub issue 和 pull request。

简介

在数据科学领域选择正确的步骤并非万能灵药。大多数数据科学家可能有自己的定制化工作流程,自动化程度或高或低,取决于他们的工作领域。在使用 Kubernetes 时,它可以极大地增强大规模工作流的自动化能力。在这篇博客文章中,我想与你分享我在进行数据科学研究并将整个工作流集成到 Kubernetes 中的旅程。

我在过去几个月进行的研究目标是找到关于 Kubernetes 仓库中数千条 GitHub issue 和 pull request (PR) 的任何有用信息。最终我构建了一个完全自动化的、运行在 Kubernetes 中的持续集成 (CI) 和持续部署 (CD) 数据科学工作流,该工作流由 KubeflowProw 提供支持。你可能不了解它们,但稍后我将详细解释它们的作用。我的工作源代码可以在 kubernetes-analysis GitHub 仓库中找到,该仓库包含了所有与源代码相关的内容以及原始数据。但是如何获取我正在谈论的这些数据呢?好吧,故事就从这里开始。

获取数据

我实验的基础是原始的 GitHub API 数据,格式为纯 JSON。必要的数据可以通过 GitHub issue endpoint 获取,该 endpoint 通过 REST API 返回所有 pull request 和常规 issue。在第一次迭代中,我导出了大约 91000 条 issue 和 pull request,生成了一个巨大的 650 MiB 数据块。由于 GitHub API 肯定 受到速率限制,这花费了我大约 8 小时的数据获取时间。为了能够将这些数据放入 GitHub 仓库,我选择使用 xz(1) 进行压缩。结果得到了一个大约 25 MiB 大小的 tarball,非常适合放入仓库。

我必须找到一种定期更新数据集的方法,因为 Kubernetes 的 issue 和 pull request 会随着时间推移被用户更新,并且新的 issue 和 pull request 也会被创建。为了实现持续更新而无需一遍又一遍地等待 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 个 issue 和 pull request 被更新!

有趣的是,Kubernetes 的创始人之一 Joe Beda 创建了第一个 GitHub issue 提及单元测试覆盖率过低。该 issue 除标题外没有进一步描述,也没有应用增强的标签,这与我们了解的近期 issue 和 pull request 不同。但是现在,我们必须更深入地探索导出的数据,以便对其进行有用的处理。

探索数据

在开始创建和训练机器学习模型之前,我们必须先了解数据的结构以及我们总体上想要实现的目标。

为了更好地了解数据量,我们来看看 Kubernetes 仓库中随时间推移创建了多少 issue 和 pull request

> ./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 模块应该会弹出一个图表,如下图所示

created all

好的,这看起来不是很惊人,但它让我们了解了项目在过去 6 年的发展情况。为了更好地了解项目开发的速度,我们可以查看 创建 vs 关闭 指标。这意味着在我们的时间线上,如果创建了一个 issue 或 pull request,y 轴加一;如果关闭了一个,则减一。现在的图表看起来像这样

> ./main analyze --created-vs-closed

created vs closed all

2018 年初,Kubernetes 项目通过出色的 fejta-bot 引入了更强的生命周期管理。它会自动关闭长期处于不活跃状态的 issue 和 pull request。这导致了大量 issue 被关闭,但 pull request 的数量并未受到同样的影响。例如,如果我们只看 pull request 的 创建 vs 关闭 指标。

> ./main analyze --created-vs-closed --pull-requests

created vs closed pull requests

总体影响不是很明显。我们可以看到,PR 图表中峰值的增加表明项目随着时间的推移进展得更快。通常,蜡烛图更适合显示这种与波动性相关的信息。我还想强调的是,项目开发在 2020 年初似乎有所放缓。

在每次分析迭代中解析原始 JSON 并不是 Python 中最快的方法。这意味着我决定将更重要的信息,例如内容、标题和创建时间解析到专门的 issuePR 类中。这些数据也将通过 pickle 序列化到仓库中,从而无需依赖 JSON 数据块也能实现整体更快的启动。

在我的分析中,pull request 与 issue 大同小异,只是它包含了一个发布说明。

Kubernetes 中的发布说明写在 PR 的描述中,位于一个单独的 release-note 块内,像这样

```release-note
I changed something extremely important and you should note that.
```

这些发布说明在发布创建过程中由专用发布工程工具(如 krel进行解析,并将成为各种CHANGELOG.md 文件和发布说明网站的一部分。这看起来有很多“魔法”,但最终,由于它们易于编辑,并且 PR 评审者可以确保我们只记录真正的面向用户的变更,而不是其他内容,因此整体发布说明的质量要高得多。

在进行数据科学时,输入数据的质量是一个关键方面。我决定关注发布说明,因为与问题和 PR 中的纯文本描述相比,它们似乎总体质量最高。除此之外,它们易于解析,我们无需剥离各种问题PR 模板的文本噪音。

标签,标签,还是标签

Kubernetes 中的问题和拉取请求(PR)在其生命周期中会被应用不同的标签。它们通常通过单个斜杠(/)分组。例如,我们有 kind/bugkind/api-changesig/nodesig/network。理解存在哪些标签组以及它们在仓库中的分布情况的一种简单方法是将它们绘制成条形图

> ./main analyze --labels-by-group

labels by group all top 25

看起来 sig/kind/area/ 标签非常常见。像 size/ 这样的标签目前可以忽略,因为这些标签是根据拉取请求的代码变更量自动应用的。我们说过要关注发布说明作为输入数据,这意味着我们必须检查 PR 上标签的分布。这意味着拉取请求上前 25 个标签是

> ./main analyze --labels-by-name --pull-requests

labels by name pull requests top 25

同样,我们可以忽略像 lgtm(对我来说看起来不错)这样的标签,因为现在应该合并的每个 PR 都必须看起来不错。包含发布说明的拉取请求会自动应用 release-note 标签,这使得进一步过滤更加容易。但这并不意味着每个包含该标签的 PR 也都包含发布说明块。该标签可能是手动应用的,而且发布说明块的解析功能并非项目一开始就存在。这意味着我们一方面可能会损失相当数量的输入数据。另一方面,我们可以专注于最高可能的数据质量,因为正确应用标签需要项目及其贡献者具有更高的成熟度。

从标签组的角度来看,我选择关注 kind/ 标签。这些标签必须由 PR 的作者手动应用,它们在大量拉取请求上可用,并且也与面向用户的变更相关。除此之外,每个拉取请求都必须选择 kind/,因为它是 PR 模板的一部分。

好了,当仅关注包含发布说明的拉取请求时,这些标签的分布是什么样的?

> ./main analyze --release-notes-stats

release notes stats

有趣的是,我们大约有 7,000 个包含发布说明的拉取请求,但只有约 5,000 个应用了 kind/ 标签。标签的分布不均,其中三分之一被标记为 kind/bug。这把我带到了数据科学之旅的下一个决定:我将构建一个二元分类器,为了简单起见,它只能区分 bug(通过 kind/bug)和非 bug(未应用该标签)。

现在的主要目标是能够根据我们从社区获得的现有历史数据,对新输入的发布说明进行分类,判断它们是否与 bug 相关。

在这样做之前,我建议您也尝试使用 ./main analyze -h 子命令来探索最新的数据集。您还可以查看我在分析仓库中提供的持续更新的资产。例如,这些是 Kubernetes 仓库中排名前 25 位的 PR 创建者

users by created pull request

构建机器学习模型

现在我们对数据集有了初步了解,可以开始构建第一个机器学习模型了。在实际构建模型之前,我们必须对从 PR 中提取的所有发布说明进行预处理。否则,模型将无法理解我们的输入。

进行首次自然语言处理 (NLP)

首先,我们必须定义一个我们想要训练的词汇表。我决定选择 Python scikit-learn 机器学习库中的TfidfVectorizer。这个向量化器能够接收我们的输入文本并从中创建一个巨大的词汇表。这就是我们所谓的词袋模型(bag-of-words),其选择的 n-gram 范围为 (1, 2)(unigrams 和 bigrams)。实际上,这意味着我们总是使用第一个词和下一个词作为一个单独的词汇表条目(bigrams)。我们也使用单个词作为词汇表条目(unigram)。TfidfVectorizer 能够跳过出现多次的词(max_df),并且需要达到最低数量(min_df)才能将词添加到词汇表中。我决定最初不改变这些值,仅仅是因为我直觉认为发布说明对于一个项目来说是独一无二的。

min_dfmax_df 和 n-gram 范围等参数可以看作是我们的一些超参数。在构建机器学习模型后,必须在一个专门的步骤中优化这些参数。这个步骤称为超参数调优(hyperparameter tuning),其基本含义是使用不同的参数训练多次并比较模型的准确性。之后,我们选择具有最佳准确性的参数。

在训练过程中,向量化器会生成一个 data/features.json 文件,其中包含整个词汇表。这让我们很好地理解了这样一个词汇表可能是什么样的

[
  
  "hostname",
  "hostname address",
  "hostname and",
  "hostname as",
  "hostname being",
  "hostname bug",
  
]

这大约产生了 50,000 个整体词袋模型的条目,这相当多。之前在不同数据集之间的分析表明,考虑这么多特征根本没有必要。一些通用数据集表明,总共 20,000 个词汇表就足够了,更高的数量不再影响准确性。为此,我们可以使用SelectKBest 特征选择器将词汇表缩减,只选择最佳特征。无论如何,我还是决定坚持前 50,000 个,以免对模型准确性产生负面影响。我们的数据量相对较少(大约 7,000 个样本),每个样本的词数较少(约 15 个),这已经让我怀疑数据是否足够。

向量化器不仅能够创建我们的词袋模型,还能够以词频-逆文档频率(term frequency–inverse document frequency, tf-idf)格式编码特征。这就是向量化器得名的原因,而这种编码的输出是机器学习模型可以直接使用的。向量化过程的所有细节可以在源代码中找到。

创建多层感知器 (MLP) 模型

我决定选择一个简单的基于 MLP 的模型,该模型借助流行的TensorFlow 框架构建。由于我们没有那么多输入数据,我们只使用两个隐藏层,因此模型基本如下所示

model

在创建模型时,必须考虑多个其他超参数。我在此不会详细讨论它们,但它们对于优化很重要,并且要与我们希望模型中包含的类数量(在本例中只有两个)相关联。

训练模型

在开始实际训练之前,我们必须将输入数据分成训练集和验证集。我选择使用约 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

混淆矩阵(Confusion Matrix)的输出表明我们的训练准确性相当好,但验证准确性可以更高一些。我们现在可以开始超参数调优,看看是否可以进一步优化模型的输出。我将把这个实验留给您,并提示使用 ./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

看起来它没有被分类为发布说明的 bug,这似乎奏效了。选择一个好的阈值也不那么容易,但坚持大于 50% 应该是最低要求。

自动化一切

下一步是找到一种自动化方法,用新数据持续更新模型。如果我更改仓库中的任何源代码,我希望获得关于模型测试结果的反馈,而无需在我自己的机器上运行训练。我希望利用我的 Kubernetes 集群中的 GPU 来加快训练速度,并在 PR 合并时自动更新数据集。

借助Kubeflow pipelines,我们可以满足大部分这些要求。我构建的管道如下所示

pipeline

首先,我们检出 PR 的源代码,这将作为输出 artifact 传递给所有其他步骤。然后,在始终更新的数据集上运行训练之前,我们增量更新 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 ingress 网关来正确地将流量路由到集群内并提供正确的服务集合。

commit-changesrollout 步骤仅在 master 分支上运行时才会执行。这些步骤确保我们在 master 分支以及 kfserving 部署中始终拥有最新的数据集。rollout 步骤创建一个新的金丝雀(canary)部署,该部署最初只接受 50% 的入站流量。金丝雀成功部署后,它将被提升为服务的新主实例。这是一种极好的方式来确保部署按预期工作,并在推出金丝雀后允许额外的测试。

但是如何在创建拉取请求时触发 Kubeflow pipelines 呢?Kubeflow 目前没有这个功能。这就是为什么我决定使用Prow,一个用于 CI/CD 目的的 Kubernetes 测试基础设施项目。

首先,一个 24 小时周期性作业确保我们在仓库中至少每天都有最新的数据。然后,如果我们创建一个拉取请求,Prow 将运行整个 Kubeflow pipeline,但不提交或 rollout 任何更改。如果我们通过 Prow 合并拉取请求,另一个作业将在 master 分支上运行,并更新数据和部署。这非常巧妙,不是吗?

新 Pull Request 的自动标注

预测 API 对测试来说很好,但现在我们需要一个真实世界的用例。Prow 支持外部插件,可用于对任何 GitHub 事件采取行动。我写了一个插件,它使用 kfserving API 根据新的拉取请求进行预测。这意味着如果我们在 kubernetes-analysis 仓库中创建一个新的拉取请求,我们将看到以下内容

pr 1


pr 2

好的,很酷,现在让我们根据现有数据集中的一个真实 bug 来修改发布说明

pr 3


pr 4

机器人编辑了自己的评论,以大约 90% 的准确率预测为 kind/bug,并自动添加了正确的标签!现在,如果我们将其改回一些不同的——显然是错误的——发布说明

pr 5


pr 6

机器人为我们完成了工作,移除了标签并告知我们它做了什么!最后,如果我们将发布说明更改为 None

pr 7


pr 8

机器人移除了评论,这很好,减少了 PR 上的文本噪音。我演示的所有内容都在单个 Kubernetes 集群中运行,这使得将 kfserving API 公开暴露给公众完全没有必要。这通过 Prow 机器人用户引入了间接的 API 速率限制,因为唯一的使用途径是通过 Prow 机器人用户。

如果您想亲自尝试,请随时在 kubernetes-analysis 中打开一个新的测试问题。这奏效是因为我不仅为拉取请求启用了该插件,也为问题启用了。

这样,我们就拥有了一个运行中的 CI 机器人,它能够根据机器学习模型对新的发布说明进行分类。如果机器人在官方 Kubernetes 仓库中运行,那么我们可以手动纠正错误的标签预测。这样,下一次训练迭代就会接收到纠正,从而使模型随着时间的推移持续改进。所有这一切都完全自动化!

总结

感谢您读到这里!这是我在 Kubernetes GitHub 仓库中进行的小小的“数据科学之旅”。还有很多其他方面可以优化,例如引入更多类(不仅仅是 kind/bug 或“没有”)或使用 Kubeflow 的Katib 进行自动超参数调优。如果您有任何问题或建议,请随时与我联系。再见!